cli_v1.rs (47675B)
1 //! One-pass command-line admission for the hardened RHI command contract. 2 3 use std::error::Error; 4 use std::ffi::OsString; 5 use std::fmt; 6 use std::path::{Component, Path, PathBuf}; 7 8 use clap::{Parser, Subcommand, ValueEnum}; 9 use radroots_runtime_paths::InstanceId; 10 11 /// The exact bootstrap profile selected by the operator. 12 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 pub enum RhiBootstrapProfileV1 { 14 ServiceHost, 15 Interactive, 16 RepoLocal, 17 } 18 19 /// The only two governed command-result encodings. 20 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] 21 pub enum RhiCliOutputModeV1 { 22 #[default] 23 Human, 24 Json, 25 } 26 27 /// The exact governed top-level RHI command inventory. 28 #[derive(PartialEq, Eq)] 29 pub enum RhiCommandV1 { 30 Run, 31 Config(RhiConfigCommandV1), 32 State(RhiStateCommandV1), 33 Identity(RhiIdentityCommandV1), 34 Status, 35 Metrics(RhiMetricsCommandV1), 36 Reconciliation(RhiReconciliationCommandV1), 37 Sources(RhiSourcesCommandV1), 38 Trade(RhiTradeCommandV1), 39 Publication(RhiPublicationCommandV1), 40 Presence(RhiPresenceCommandV1), 41 Doctor, 42 } 43 44 /// Governed configuration commands. 45 #[derive(PartialEq, Eq)] 46 pub enum RhiConfigCommandV1 { 47 Init, 48 Validate, 49 Show, 50 Schema, 51 Apply(RhiConfigApplyArgsV1), 52 } 53 54 /// Governed state commands. 55 #[derive(PartialEq, Eq)] 56 pub enum RhiStateCommandV1 { 57 Init, 58 Status, 59 Backup(RhiStateBackupArgsV1), 60 Restore(RhiStateRestoreArgsV1), 61 Verify, 62 Migrate, 63 } 64 65 /// Governed service-identity commands. 66 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 67 pub enum RhiIdentityCommandV1 { 68 Init, 69 Status, 70 ExportPublic, 71 } 72 73 /// Governed metrics commands. 74 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 75 pub enum RhiMetricsCommandV1 { 76 Snapshot, 77 } 78 79 /// Governed reconciliation commands. 80 #[derive(PartialEq, Eq)] 81 pub enum RhiReconciliationCommandV1 { 82 Status, 83 Jobs(RhiPageQueryArgsV1), 84 Refresh(RhiReconciliationRefreshArgsV1), 85 } 86 87 /// Governed evidence-source commands. 88 #[derive(PartialEq, Eq)] 89 pub enum RhiSourcesCommandV1 { 90 List(RhiPageQueryArgsV1), 91 } 92 93 /// Governed trade-query commands. 94 #[derive(PartialEq, Eq)] 95 pub enum RhiTradeCommandV1 { 96 Projection(RhiTradeArgsV1), 97 ReportCurrent(RhiTradeArgsV1), 98 Reports(RhiTradePageArgsV1), 99 } 100 101 /// Governed publication commands. 102 #[derive(PartialEq, Eq)] 103 pub enum RhiPublicationCommandV1 { 104 Backlog(RhiPageQueryArgsV1), 105 Targets(RhiPageQueryArgsV1), 106 Retry(RhiPublicationRetryArgsV1), 107 } 108 109 /// Governed desired-presence commands. 110 #[derive(PartialEq, Eq)] 111 pub enum RhiPresenceCommandV1 { 112 Desired, 113 Render(RhiPresenceMutationArgsV1), 114 Refresh(RhiPresenceMutationArgsV1), 115 } 116 117 /// Exact offline configuration-apply input. 118 #[derive(PartialEq, Eq)] 119 pub struct RhiConfigApplyArgsV1 { 120 candidate_config: PathBuf, 121 } 122 123 impl RhiConfigApplyArgsV1 { 124 #[must_use] 125 pub fn candidate_config(&self) -> &Path { 126 &self.candidate_config 127 } 128 } 129 130 /// Exact live state-backup input. 131 #[derive(PartialEq, Eq)] 132 pub struct RhiStateBackupArgsV1 { 133 operation_id: Box<str>, 134 target: PathBuf, 135 expected_generation: u64, 136 } 137 138 impl RhiStateBackupArgsV1 { 139 #[must_use] 140 pub fn operation_id(&self) -> &str { 141 &self.operation_id 142 } 143 144 #[must_use] 145 pub fn target(&self) -> &Path { 146 &self.target 147 } 148 149 #[must_use] 150 pub const fn expected_generation(&self) -> u64 { 151 self.expected_generation 152 } 153 } 154 155 /// Exact offline state-restore input. 156 #[derive(PartialEq, Eq)] 157 pub struct RhiStateRestoreArgsV1 { 158 manifest: PathBuf, 159 manifest_sha256: Box<str>, 160 bundle: PathBuf, 161 maximum_state_bytes: u64, 162 } 163 164 impl RhiStateRestoreArgsV1 { 165 #[must_use] 166 pub fn manifest(&self) -> &Path { 167 &self.manifest 168 } 169 170 #[must_use] 171 pub fn manifest_sha256(&self) -> &str { 172 &self.manifest_sha256 173 } 174 175 #[must_use] 176 pub fn bundle(&self) -> &Path { 177 &self.bundle 178 } 179 180 #[must_use] 181 pub const fn maximum_state_bytes(&self) -> u64 { 182 self.maximum_state_bytes 183 } 184 } 185 186 /// Bounded stable pagination input shared by list commands. 187 #[derive(PartialEq, Eq)] 188 pub struct RhiPageQueryArgsV1 { 189 limit: u16, 190 cursor: Option<Box<str>>, 191 } 192 193 impl RhiPageQueryArgsV1 { 194 #[must_use] 195 pub const fn limit(&self) -> u16 { 196 self.limit 197 } 198 199 #[must_use] 200 pub fn cursor(&self) -> Option<&str> { 201 self.cursor.as_deref() 202 } 203 } 204 205 /// Exact trade selection for one live query. 206 #[derive(PartialEq, Eq)] 207 pub struct RhiTradeArgsV1 { 208 trade_id: Box<str>, 209 } 210 211 impl RhiTradeArgsV1 { 212 #[must_use] 213 pub fn trade_id(&self) -> &str { 214 &self.trade_id 215 } 216 } 217 218 /// Exact trade selection plus bounded report pagination. 219 #[derive(PartialEq, Eq)] 220 pub struct RhiTradePageArgsV1 { 221 trade_id: Box<str>, 222 page: RhiPageQueryArgsV1, 223 } 224 225 impl RhiTradePageArgsV1 { 226 #[must_use] 227 pub fn trade_id(&self) -> &str { 228 &self.trade_id 229 } 230 231 #[must_use] 232 pub const fn page(&self) -> &RhiPageQueryArgsV1 { 233 &self.page 234 } 235 } 236 237 /// Exact refresh request and idempotency identity. 238 #[derive(PartialEq, Eq)] 239 pub struct RhiReconciliationRefreshArgsV1 { 240 operation_id: Box<str>, 241 trade_id: Box<str>, 242 expected_dirty_generation: u64, 243 } 244 245 impl RhiReconciliationRefreshArgsV1 { 246 #[must_use] 247 pub fn operation_id(&self) -> &str { 248 &self.operation_id 249 } 250 251 #[must_use] 252 pub fn trade_id(&self) -> &str { 253 &self.trade_id 254 } 255 256 #[must_use] 257 pub const fn expected_dirty_generation(&self) -> u64 { 258 self.expected_dirty_generation 259 } 260 } 261 262 /// Exact publication retry request and idempotency identity. 263 #[derive(PartialEq, Eq)] 264 pub struct RhiPublicationRetryArgsV1 { 265 operation_id: Box<str>, 266 workflow_id: Box<str>, 267 expected_generation: u64, 268 } 269 270 impl RhiPublicationRetryArgsV1 { 271 #[must_use] 272 pub fn operation_id(&self) -> &str { 273 &self.operation_id 274 } 275 276 #[must_use] 277 pub fn workflow_id(&self) -> &str { 278 &self.workflow_id 279 } 280 281 #[must_use] 282 pub const fn expected_generation(&self) -> u64 { 283 self.expected_generation 284 } 285 } 286 287 /// Exact presence mutation request and idempotency identity. 288 #[derive(PartialEq, Eq)] 289 pub struct RhiPresenceMutationArgsV1 { 290 operation_id: Box<str>, 291 expected_generation: u64, 292 } 293 294 impl RhiPresenceMutationArgsV1 { 295 #[must_use] 296 pub fn operation_id(&self) -> &str { 297 &self.operation_id 298 } 299 300 #[must_use] 301 pub const fn expected_generation(&self) -> u64 { 302 self.expected_generation 303 } 304 } 305 306 macro_rules! redacted_debug { 307 ($($type:ty),+ $(,)?) => { 308 $( 309 impl fmt::Debug for $type { 310 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 311 formatter.write_str(concat!(stringify!($type), "([redacted])")) 312 } 313 } 314 )+ 315 }; 316 } 317 318 redacted_debug!( 319 RhiConfigApplyArgsV1, 320 RhiStateBackupArgsV1, 321 RhiStateRestoreArgsV1, 322 RhiPageQueryArgsV1, 323 RhiTradeArgsV1, 324 RhiTradePageArgsV1, 325 RhiReconciliationRefreshArgsV1, 326 RhiPublicationRetryArgsV1, 327 RhiPresenceMutationArgsV1, 328 ); 329 330 impl fmt::Debug for RhiCommandV1 { 331 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 332 formatter.write_str(match self { 333 Self::Run => "RhiCommandV1::Run", 334 Self::Config(_) => "RhiCommandV1::Config([redacted])", 335 Self::State(_) => "RhiCommandV1::State([redacted])", 336 Self::Identity(_) => "RhiCommandV1::Identity([redacted])", 337 Self::Status => "RhiCommandV1::Status", 338 Self::Metrics(_) => "RhiCommandV1::Metrics([redacted])", 339 Self::Reconciliation(_) => "RhiCommandV1::Reconciliation([redacted])", 340 Self::Sources(_) => "RhiCommandV1::Sources([redacted])", 341 Self::Trade(_) => "RhiCommandV1::Trade([redacted])", 342 Self::Publication(_) => "RhiCommandV1::Publication([redacted])", 343 Self::Presence(_) => "RhiCommandV1::Presence([redacted])", 344 Self::Doctor => "RhiCommandV1::Doctor", 345 }) 346 } 347 } 348 349 /// The only three process authorities selected by the hardened CLI. 350 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 351 pub enum RhiCliPrimaryAuthorityV1 { 352 Daemon, 353 Offline, 354 LiveUnixAdmin, 355 } 356 357 /// The closed offline operation classes selected before any state access. 358 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 359 pub enum RhiCliOfflineOperationV1 { 360 Config, 361 StateExclusive, 362 IdentityExclusive, 363 Doctor, 364 } 365 366 /// The closed Unix-admin operations reachable from the command inventory. 367 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 368 pub enum RhiCliAdminOperationV1 { 369 Status, 370 EffectiveConfig, 371 IdentityStatus, 372 IdentityPublic, 373 StateStatus, 374 StateBackup, 375 MetricsSnapshot, 376 ReconciliationStatus, 377 ReconciliationJobs, 378 ReconciliationRefresh, 379 Sources, 380 TradeProjection, 381 TradeReportCurrent, 382 TradeReports, 383 PublicationBacklog, 384 PublicationTargets, 385 PublicationRetry, 386 PresenceDesired, 387 PresenceRender, 388 PresenceRefresh, 389 } 390 391 #[cfg(any(target_os = "linux", target_os = "macos"))] 392 impl RhiCliAdminOperationV1 { 393 /// Returns the exact native Unix-admin route selected by this operation. 394 #[must_use] 395 pub const fn route(self) -> crate::RhiAdminRoute { 396 match self { 397 Self::Status => crate::RhiAdminRoute::Status, 398 Self::EffectiveConfig => crate::RhiAdminRoute::EffectiveConfig, 399 Self::IdentityStatus => crate::RhiAdminRoute::IdentityStatus, 400 Self::IdentityPublic => crate::RhiAdminRoute::IdentityPublic, 401 Self::StateStatus => crate::RhiAdminRoute::StateStatus, 402 Self::StateBackup => crate::RhiAdminRoute::StateBackup, 403 Self::MetricsSnapshot => crate::RhiAdminRoute::MetricsSnapshot, 404 Self::ReconciliationStatus => crate::RhiAdminRoute::ReconciliationStatus, 405 Self::ReconciliationJobs => crate::RhiAdminRoute::ReconciliationJobs, 406 Self::ReconciliationRefresh => crate::RhiAdminRoute::ReconciliationRefresh, 407 Self::Sources => crate::RhiAdminRoute::Sources, 408 Self::TradeProjection => crate::RhiAdminRoute::TradeProjection, 409 Self::TradeReportCurrent => crate::RhiAdminRoute::TradeReportCurrent, 410 Self::TradeReports => crate::RhiAdminRoute::TradeReports, 411 Self::PublicationBacklog => crate::RhiAdminRoute::PublicationBacklog, 412 Self::PublicationTargets => crate::RhiAdminRoute::PublicationTargets, 413 Self::PublicationRetry => crate::RhiAdminRoute::PublicationRetry, 414 Self::PresenceDesired => crate::RhiAdminRoute::PresenceDesired, 415 Self::PresenceRender => crate::RhiAdminRoute::PresenceRender, 416 Self::PresenceRefresh => crate::RhiAdminRoute::PresenceRefresh, 417 } 418 } 419 } 420 421 /// A sealed, side-effect-free execution plan for one admitted CLI invocation. 422 /// 423 /// Construction is owned by [`plan_rhi_cli_v1`]. Live commands carry only a 424 /// governed Unix-admin operation and never receive an offline or direct-SQLite 425 /// fallback. 426 /// 427 /// ```compile_fail 428 /// use rhi::{RhiCliExecutionPlanV1, RhiCliPrimaryAuthorityV1}; 429 /// 430 /// let _ = RhiCliExecutionPlanV1 { 431 /// primary_authority: RhiCliPrimaryAuthorityV1::Offline, 432 /// offline_operation: None, 433 /// admin_operation: None, 434 /// }; 435 /// ``` 436 #[derive(Clone, Copy, PartialEq, Eq)] 437 pub struct RhiCliExecutionPlanV1 { 438 primary_authority: RhiCliPrimaryAuthorityV1, 439 offline_operation: Option<RhiCliOfflineOperationV1>, 440 admin_operation: Option<RhiCliAdminOperationV1>, 441 } 442 443 impl RhiCliExecutionPlanV1 { 444 /// Returns the sole selected process authority. 445 #[must_use] 446 pub const fn primary_authority(&self) -> RhiCliPrimaryAuthorityV1 { 447 self.primary_authority 448 } 449 450 /// Returns the bounded offline operation, when the plan admits one. 451 #[must_use] 452 pub const fn offline_operation(&self) -> Option<RhiCliOfflineOperationV1> { 453 self.offline_operation 454 } 455 456 /// Returns the bounded Unix-admin operation, when the plan admits one. 457 #[must_use] 458 pub const fn admin_operation(&self) -> Option<RhiCliAdminOperationV1> { 459 self.admin_operation 460 } 461 } 462 463 impl fmt::Debug for RhiCliExecutionPlanV1 { 464 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 465 formatter 466 .debug_struct("RhiCliExecutionPlanV1") 467 .field("primary_authority", &self.primary_authority) 468 .field("offline_operation", &self.offline_operation) 469 .field("admin_operation", &self.admin_operation) 470 .finish() 471 } 472 } 473 474 /// Stable source-free classification for command-line admission failures. 475 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 476 pub enum RhiCliV1ErrorKind { 477 InvalidArguments, 478 InvalidInstance, 479 InvalidRepoLocalRoot, 480 UnexpectedRepoLocalRoot, 481 InvalidConfigPath, 482 InvalidCommandInput, 483 } 484 485 impl RhiCliV1ErrorKind { 486 const fn message(self) -> &'static str { 487 match self { 488 Self::InvalidArguments => "command-line arguments are invalid", 489 Self::InvalidInstance => "instance identifier is invalid", 490 Self::InvalidRepoLocalRoot => "repo-local profile requires a valid absolute root", 491 Self::UnexpectedRepoLocalRoot => { 492 "repo-local root is forbidden outside the repo-local profile" 493 } 494 Self::InvalidConfigPath => "configuration path must be absolute without traversal", 495 Self::InvalidCommandInput => "command input is invalid", 496 } 497 } 498 } 499 500 /// One safe command-line admission failure. 501 #[derive(Clone, Copy, PartialEq, Eq)] 502 pub struct RhiCliV1Error { 503 kind: RhiCliV1ErrorKind, 504 } 505 506 impl RhiCliV1Error { 507 const fn new(kind: RhiCliV1ErrorKind) -> Self { 508 Self { kind } 509 } 510 511 /// Returns the stable failure classification. 512 #[must_use] 513 pub const fn kind(self) -> RhiCliV1ErrorKind { 514 self.kind 515 } 516 } 517 518 impl fmt::Debug for RhiCliV1Error { 519 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 520 formatter 521 .debug_struct("RhiCliV1Error") 522 .field("kind", &self.kind) 523 .finish() 524 } 525 } 526 527 impl fmt::Display for RhiCliV1Error { 528 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 529 formatter.write_str(self.kind.message()) 530 } 531 } 532 533 impl Error for RhiCliV1Error {} 534 535 /// A validated one-pass RHI bootstrap and command selection. 536 pub struct RhiCliInvocationV1 { 537 profile: RhiBootstrapProfileV1, 538 instance: InstanceId, 539 repo_local_root: Option<PathBuf>, 540 config_path: Option<PathBuf>, 541 output_mode: RhiCliOutputModeV1, 542 command: RhiCommandV1, 543 } 544 545 impl RhiCliInvocationV1 { 546 /// Returns the explicitly selected bootstrap profile. 547 #[must_use] 548 pub const fn profile(&self) -> RhiBootstrapProfileV1 { 549 self.profile 550 } 551 552 /// Returns the validated instance identifier. 553 #[must_use] 554 pub const fn instance(&self) -> &InstanceId { 555 &self.instance 556 } 557 558 /// Returns the explicit repo-local root, when selected. 559 #[must_use] 560 pub fn repo_local_root(&self) -> Option<&Path> { 561 self.repo_local_root.as_deref() 562 } 563 564 /// Returns the optional explicit configuration path. 565 #[must_use] 566 pub fn config_path(&self) -> Option<&Path> { 567 self.config_path.as_deref() 568 } 569 570 /// Returns the selected human or governed machine-result encoding. 571 #[must_use] 572 pub const fn output_mode(&self) -> RhiCliOutputModeV1 { 573 self.output_mode 574 } 575 576 /// Returns the exact governed command selection. 577 #[must_use] 578 pub const fn command(&self) -> &RhiCommandV1 { 579 &self.command 580 } 581 } 582 583 impl fmt::Debug for RhiCliInvocationV1 { 584 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 585 formatter 586 .debug_struct("RhiCliInvocationV1") 587 .field("profile", &self.profile) 588 .field("instance", &"[redacted]") 589 .field( 590 "repo_local_root", 591 &self.repo_local_root.as_ref().map(|_| "[redacted]"), 592 ) 593 .field( 594 "config_path", 595 &self.config_path.as_ref().map(|_| "[redacted]"), 596 ) 597 .field("output_mode", &self.output_mode) 598 .field("command", &self.command) 599 .finish() 600 } 601 } 602 603 /// Parses the exact hardened RHI bootstrap and command tree once. 604 /// 605 /// The iterator must include the program name as its first element. Clap's 606 /// dependency-owned diagnostic is deliberately discarded so caller-controlled 607 /// argument text cannot escape through this crate's stable error boundary. 608 pub fn parse_rhi_cli_v1_from<I, T>(arguments: I) -> Result<RhiCliInvocationV1, RhiCliV1Error> 609 where 610 I: IntoIterator<Item = T>, 611 T: Into<OsString> + Clone, 612 { 613 let parsed = RawRhiCliV1::try_parse_from(arguments) 614 .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?; 615 let profile = parsed 616 .profile 617 .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))? 618 .into(); 619 let instance = parsed 620 .instance 621 .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?; 622 let instance = InstanceId::new(instance) 623 .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidInstance))?; 624 validate_bootstrap_paths( 625 profile, 626 parsed.repo_local_root.as_deref(), 627 parsed.config.as_deref(), 628 )?; 629 630 Ok(RhiCliInvocationV1 { 631 profile, 632 instance, 633 repo_local_root: parsed.repo_local_root, 634 config_path: parsed.config, 635 output_mode: parsed.output.into(), 636 command: admit_command(parsed.command)?, 637 }) 638 } 639 640 /// Selects the sole permitted execution authority for an admitted command. 641 /// 642 /// This function performs no filesystem, database, socket, environment, task, 643 /// or process work. Later executors consume the plan without reparsing process 644 /// arguments. No live command receives direct SQLite or offline fallback 645 /// authority. 646 #[must_use] 647 pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecutionPlanV1 { 648 match &invocation.command { 649 RhiCommandV1::Run => daemon_plan(), 650 RhiCommandV1::Config(RhiConfigCommandV1::Init) 651 | RhiCommandV1::Config(RhiConfigCommandV1::Validate) 652 | RhiCommandV1::Config(RhiConfigCommandV1::Schema) 653 | RhiCommandV1::Config(RhiConfigCommandV1::Apply(_)) => { 654 offline_plan(RhiCliOfflineOperationV1::Config) 655 } 656 RhiCommandV1::Config(RhiConfigCommandV1::Show) => { 657 admin_plan(RhiCliAdminOperationV1::EffectiveConfig) 658 } 659 RhiCommandV1::State(RhiStateCommandV1::Init) 660 | RhiCommandV1::State(RhiStateCommandV1::Restore(_)) 661 | RhiCommandV1::State(RhiStateCommandV1::Verify) 662 | RhiCommandV1::State(RhiStateCommandV1::Migrate) => { 663 offline_plan(RhiCliOfflineOperationV1::StateExclusive) 664 } 665 RhiCommandV1::State(RhiStateCommandV1::Status) => { 666 admin_plan(RhiCliAdminOperationV1::StateStatus) 667 } 668 RhiCommandV1::State(RhiStateCommandV1::Backup(_)) => { 669 admin_plan(RhiCliAdminOperationV1::StateBackup) 670 } 671 RhiCommandV1::Identity(RhiIdentityCommandV1::Init) => { 672 offline_plan(RhiCliOfflineOperationV1::IdentityExclusive) 673 } 674 RhiCommandV1::Identity(RhiIdentityCommandV1::Status) => { 675 admin_plan(RhiCliAdminOperationV1::IdentityStatus) 676 } 677 RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic) => { 678 admin_plan(RhiCliAdminOperationV1::IdentityPublic) 679 } 680 RhiCommandV1::Status => admin_plan(RhiCliAdminOperationV1::Status), 681 RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot) => { 682 admin_plan(RhiCliAdminOperationV1::MetricsSnapshot) 683 } 684 RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => { 685 admin_plan(RhiCliAdminOperationV1::ReconciliationStatus) 686 } 687 RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(_)) => { 688 admin_plan(RhiCliAdminOperationV1::ReconciliationJobs) 689 } 690 RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(_)) => { 691 admin_plan(RhiCliAdminOperationV1::ReconciliationRefresh) 692 } 693 RhiCommandV1::Sources(RhiSourcesCommandV1::List(_)) => { 694 admin_plan(RhiCliAdminOperationV1::Sources) 695 } 696 RhiCommandV1::Trade(RhiTradeCommandV1::Projection(_)) => { 697 admin_plan(RhiCliAdminOperationV1::TradeProjection) 698 } 699 RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(_)) => { 700 admin_plan(RhiCliAdminOperationV1::TradeReportCurrent) 701 } 702 RhiCommandV1::Trade(RhiTradeCommandV1::Reports(_)) => { 703 admin_plan(RhiCliAdminOperationV1::TradeReports) 704 } 705 RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(_)) => { 706 admin_plan(RhiCliAdminOperationV1::PublicationBacklog) 707 } 708 RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(_)) => { 709 admin_plan(RhiCliAdminOperationV1::PublicationTargets) 710 } 711 RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(_)) => { 712 admin_plan(RhiCliAdminOperationV1::PublicationRetry) 713 } 714 RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => { 715 admin_plan(RhiCliAdminOperationV1::PresenceDesired) 716 } 717 RhiCommandV1::Presence(RhiPresenceCommandV1::Render(_)) => { 718 admin_plan(RhiCliAdminOperationV1::PresenceRender) 719 } 720 RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(_)) => { 721 admin_plan(RhiCliAdminOperationV1::PresenceRefresh) 722 } 723 RhiCommandV1::Doctor => offline_plan(RhiCliOfflineOperationV1::Doctor), 724 } 725 } 726 727 const fn daemon_plan() -> RhiCliExecutionPlanV1 { 728 RhiCliExecutionPlanV1 { 729 primary_authority: RhiCliPrimaryAuthorityV1::Daemon, 730 offline_operation: None, 731 admin_operation: None, 732 } 733 } 734 735 const fn offline_plan(operation: RhiCliOfflineOperationV1) -> RhiCliExecutionPlanV1 { 736 RhiCliExecutionPlanV1 { 737 primary_authority: RhiCliPrimaryAuthorityV1::Offline, 738 offline_operation: Some(operation), 739 admin_operation: None, 740 } 741 } 742 743 const fn admin_plan(operation: RhiCliAdminOperationV1) -> RhiCliExecutionPlanV1 { 744 RhiCliExecutionPlanV1 { 745 primary_authority: RhiCliPrimaryAuthorityV1::LiveUnixAdmin, 746 offline_operation: None, 747 admin_operation: Some(operation), 748 } 749 } 750 751 fn validate_bootstrap_paths( 752 profile: RhiBootstrapProfileV1, 753 repo_local_root: Option<&Path>, 754 config_path: Option<&Path>, 755 ) -> Result<(), RhiCliV1Error> { 756 match (profile, repo_local_root) { 757 (RhiBootstrapProfileV1::RepoLocal, Some(root)) if valid_absolute_path(root, true) => {} 758 (RhiBootstrapProfileV1::RepoLocal, _) => { 759 return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidRepoLocalRoot)); 760 } 761 (_, Some(_)) => { 762 return Err(RhiCliV1Error::new( 763 RhiCliV1ErrorKind::UnexpectedRepoLocalRoot, 764 )); 765 } 766 (_, None) => {} 767 } 768 769 if config_path.is_some_and(|path| !valid_absolute_path(path, true)) { 770 return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidConfigPath)); 771 } 772 Ok(()) 773 } 774 775 fn valid_absolute_path(path: &Path, require_non_root: bool) -> bool { 776 path.is_absolute() 777 && (!require_non_root || path.parent().is_some()) 778 && path 779 .to_str() 780 .is_some_and(|value| !value.is_empty() && value.len() <= 4_096) 781 && !path 782 .components() 783 .any(|component| matches!(component, Component::ParentDir)) 784 } 785 786 #[derive(Parser)] 787 #[command(name = "rhi", disable_help_subcommand = true)] 788 struct RawRhiCliV1 { 789 #[arg(long, global = true, value_enum)] 790 profile: Option<RawProfile>, 791 #[arg(long, global = true)] 792 instance: Option<String>, 793 #[arg(long = "repo-local-root", global = true)] 794 repo_local_root: Option<PathBuf>, 795 #[arg(long, global = true)] 796 config: Option<PathBuf>, 797 #[arg(long, global = true, value_enum, default_value_t = RawOutputMode::Human)] 798 output: RawOutputMode, 799 #[command(subcommand)] 800 command: RawCommand, 801 } 802 803 #[derive(Clone, Copy, ValueEnum)] 804 enum RawProfile { 805 ServiceHost, 806 Interactive, 807 RepoLocal, 808 } 809 810 impl From<RawProfile> for RhiBootstrapProfileV1 { 811 fn from(value: RawProfile) -> Self { 812 match value { 813 RawProfile::ServiceHost => Self::ServiceHost, 814 RawProfile::Interactive => Self::Interactive, 815 RawProfile::RepoLocal => Self::RepoLocal, 816 } 817 } 818 } 819 820 #[derive(Clone, Copy, Default, ValueEnum)] 821 enum RawOutputMode { 822 #[default] 823 Human, 824 Json, 825 } 826 827 impl From<RawOutputMode> for RhiCliOutputModeV1 { 828 fn from(value: RawOutputMode) -> Self { 829 match value { 830 RawOutputMode::Human => Self::Human, 831 RawOutputMode::Json => Self::Json, 832 } 833 } 834 } 835 836 #[derive(Subcommand)] 837 enum RawCommand { 838 Run, 839 Config { 840 #[command(subcommand)] 841 command: RawConfigCommand, 842 }, 843 State { 844 #[command(subcommand)] 845 command: RawStateCommand, 846 }, 847 Identity { 848 #[command(subcommand)] 849 command: RawIdentityCommand, 850 }, 851 Status, 852 Metrics { 853 #[command(subcommand)] 854 command: RawMetricsCommand, 855 }, 856 Reconciliation { 857 #[command(subcommand)] 858 command: RawReconciliationCommand, 859 }, 860 Sources { 861 #[command(subcommand)] 862 command: RawSourcesCommand, 863 }, 864 Trade { 865 #[command(subcommand)] 866 command: RawTradeCommand, 867 }, 868 Publication { 869 #[command(subcommand)] 870 command: RawPublicationCommand, 871 }, 872 Presence { 873 #[command(subcommand)] 874 command: RawPresenceCommand, 875 }, 876 Doctor, 877 } 878 879 macro_rules! command_enum { 880 ($raw:ident, $public:ident, { $($variant:ident),+ $(,)? }) => { 881 #[derive(Subcommand)] 882 enum $raw { 883 $($variant),+ 884 } 885 886 impl From<$raw> for $public { 887 fn from(value: $raw) -> Self { 888 match value { 889 $($raw::$variant => Self::$variant),+ 890 } 891 } 892 } 893 }; 894 } 895 896 #[derive(Subcommand)] 897 enum RawConfigCommand { 898 Init, 899 Validate, 900 Show, 901 Schema, 902 Apply { 903 #[arg(long = "candidate-config")] 904 candidate_config: PathBuf, 905 }, 906 } 907 908 #[derive(Subcommand)] 909 enum RawStateCommand { 910 Init, 911 Status, 912 Backup { 913 #[arg(long = "operation-id")] 914 operation_id: String, 915 #[arg(long)] 916 target: PathBuf, 917 #[arg(long = "expected-generation")] 918 expected_generation: u64, 919 #[arg(long, required = true)] 920 confirm: bool, 921 }, 922 Restore { 923 #[arg(long)] 924 manifest: PathBuf, 925 #[arg(long = "manifest-sha256")] 926 manifest_sha256: String, 927 #[arg(long)] 928 bundle: PathBuf, 929 #[arg(long = "maximum-state-bytes")] 930 maximum_state_bytes: u64, 931 #[arg(long, required = true)] 932 confirm: bool, 933 }, 934 Verify, 935 Migrate, 936 } 937 938 command_enum!(RawIdentityCommand, RhiIdentityCommandV1, { 939 Init, 940 Status, 941 ExportPublic, 942 }); 943 command_enum!(RawMetricsCommand, RhiMetricsCommandV1, { Snapshot }); 944 945 #[derive(Subcommand)] 946 enum RawReconciliationCommand { 947 Status, 948 Jobs { 949 #[command(flatten)] 950 page: RawPageQuery, 951 }, 952 Refresh { 953 #[arg(long = "operation-id")] 954 operation_id: String, 955 #[arg(long = "trade-id")] 956 trade_id: String, 957 #[arg(long = "expected-dirty-generation")] 958 expected_dirty_generation: u64, 959 }, 960 } 961 962 #[derive(Subcommand)] 963 enum RawSourcesCommand { 964 List { 965 #[command(flatten)] 966 page: RawPageQuery, 967 }, 968 } 969 970 #[derive(Subcommand)] 971 enum RawTradeCommand { 972 Projection { 973 #[arg(long = "trade-id")] 974 trade_id: String, 975 }, 976 ReportCurrent { 977 #[arg(long = "trade-id")] 978 trade_id: String, 979 }, 980 Reports { 981 #[arg(long = "trade-id")] 982 trade_id: String, 983 #[command(flatten)] 984 page: RawPageQuery, 985 }, 986 } 987 988 #[derive(Subcommand)] 989 enum RawPublicationCommand { 990 Backlog { 991 #[command(flatten)] 992 page: RawPageQuery, 993 }, 994 Targets { 995 #[command(flatten)] 996 page: RawPageQuery, 997 }, 998 Retry { 999 #[arg(long = "operation-id")] 1000 operation_id: String, 1001 #[arg(long = "workflow-id")] 1002 workflow_id: String, 1003 #[arg(long = "expected-generation")] 1004 expected_generation: u64, 1005 }, 1006 } 1007 1008 #[derive(Subcommand)] 1009 enum RawPresenceCommand { 1010 Desired, 1011 Render { 1012 #[arg(long = "operation-id")] 1013 operation_id: String, 1014 #[arg(long = "expected-generation")] 1015 expected_generation: u64, 1016 }, 1017 Refresh { 1018 #[arg(long = "operation-id")] 1019 operation_id: String, 1020 #[arg(long = "expected-generation")] 1021 expected_generation: u64, 1022 }, 1023 } 1024 1025 #[derive(clap::Args)] 1026 struct RawPageQuery { 1027 #[arg(long, default_value_t = 100)] 1028 limit: u16, 1029 #[arg(long)] 1030 cursor: Option<String>, 1031 } 1032 1033 fn admit_command(command: RawCommand) -> Result<RhiCommandV1, RhiCliV1Error> { 1034 let invalid = || RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidCommandInput); 1035 let page = |value: RawPageQuery| { 1036 if !(1..=200).contains(&value.limit) 1037 || value.cursor.as_deref().is_some_and(|cursor| { 1038 cursor.is_empty() 1039 || cursor.len() > 512 1040 || cursor != cursor.trim() 1041 || cursor.chars().any(char::is_control) 1042 }) 1043 { 1044 return Err(invalid()); 1045 } 1046 Ok(RhiPageQueryArgsV1 { 1047 limit: value.limit, 1048 cursor: value.cursor.map(String::into_boxed_str), 1049 }) 1050 }; 1051 let bounded_id = |value: String| { 1052 if value.is_empty() 1053 || value.len() > 128 1054 || value != value.trim() 1055 || value.chars().any(char::is_control) 1056 { 1057 Err(invalid()) 1058 } else { 1059 Ok(value.into_boxed_str()) 1060 } 1061 }; 1062 let trade_id = |value: String| match radroots_event::id::TradeId::parse(&value) { 1063 Ok(parsed) if parsed.to_hex() == value => Ok(value.into_boxed_str()), 1064 Ok(_) | Err(_) => Err(invalid()), 1065 }; 1066 Ok(match command { 1067 RawCommand::Run => RhiCommandV1::Run, 1068 RawCommand::Config { command } => RhiCommandV1::Config(match command { 1069 RawConfigCommand::Init => RhiConfigCommandV1::Init, 1070 RawConfigCommand::Validate => RhiConfigCommandV1::Validate, 1071 RawConfigCommand::Show => RhiConfigCommandV1::Show, 1072 RawConfigCommand::Schema => RhiConfigCommandV1::Schema, 1073 RawConfigCommand::Apply { candidate_config } 1074 if valid_absolute_path(&candidate_config, true) => 1075 { 1076 RhiConfigCommandV1::Apply(RhiConfigApplyArgsV1 { candidate_config }) 1077 } 1078 RawConfigCommand::Apply { .. } => return Err(invalid()), 1079 }), 1080 RawCommand::State { command } => RhiCommandV1::State(match command { 1081 RawStateCommand::Init => RhiStateCommandV1::Init, 1082 RawStateCommand::Status => RhiStateCommandV1::Status, 1083 RawStateCommand::Backup { 1084 operation_id, 1085 target, 1086 expected_generation, 1087 confirm: true, 1088 } if valid_absolute_path(&target, true) => { 1089 RhiStateCommandV1::Backup(RhiStateBackupArgsV1 { 1090 operation_id: bounded_id(operation_id)?, 1091 target, 1092 expected_generation, 1093 }) 1094 } 1095 RawStateCommand::Backup { .. } => return Err(invalid()), 1096 RawStateCommand::Restore { 1097 manifest, 1098 manifest_sha256, 1099 bundle, 1100 maximum_state_bytes, 1101 confirm: true, 1102 } if valid_absolute_path(&manifest, true) 1103 && valid_absolute_path(&bundle, true) 1104 && maximum_state_bytes != 0 1105 && is_lower_hex(&manifest_sha256, 64) => 1106 { 1107 RhiStateCommandV1::Restore(RhiStateRestoreArgsV1 { 1108 manifest, 1109 manifest_sha256: manifest_sha256.into_boxed_str(), 1110 bundle, 1111 maximum_state_bytes, 1112 }) 1113 } 1114 RawStateCommand::Restore { .. } => return Err(invalid()), 1115 RawStateCommand::Verify => RhiStateCommandV1::Verify, 1116 RawStateCommand::Migrate => RhiStateCommandV1::Migrate, 1117 }), 1118 RawCommand::Identity { command } => RhiCommandV1::Identity(command.into()), 1119 RawCommand::Status => RhiCommandV1::Status, 1120 RawCommand::Metrics { command } => RhiCommandV1::Metrics(command.into()), 1121 RawCommand::Reconciliation { command } => RhiCommandV1::Reconciliation(match command { 1122 RawReconciliationCommand::Status => RhiReconciliationCommandV1::Status, 1123 RawReconciliationCommand::Jobs { page: value } => { 1124 RhiReconciliationCommandV1::Jobs(page(value)?) 1125 } 1126 RawReconciliationCommand::Refresh { 1127 operation_id, 1128 trade_id: selected_trade, 1129 expected_dirty_generation, 1130 } => RhiReconciliationCommandV1::Refresh(RhiReconciliationRefreshArgsV1 { 1131 operation_id: bounded_id(operation_id)?, 1132 trade_id: trade_id(selected_trade)?, 1133 expected_dirty_generation, 1134 }), 1135 }), 1136 RawCommand::Sources { command } => RhiCommandV1::Sources(match command { 1137 RawSourcesCommand::List { page: value } => RhiSourcesCommandV1::List(page(value)?), 1138 }), 1139 RawCommand::Trade { command } => RhiCommandV1::Trade(match command { 1140 RawTradeCommand::Projection { trade_id: value } => { 1141 RhiTradeCommandV1::Projection(RhiTradeArgsV1 { 1142 trade_id: trade_id(value)?, 1143 }) 1144 } 1145 RawTradeCommand::ReportCurrent { trade_id: value } => { 1146 RhiTradeCommandV1::ReportCurrent(RhiTradeArgsV1 { 1147 trade_id: trade_id(value)?, 1148 }) 1149 } 1150 RawTradeCommand::Reports { 1151 trade_id: value, 1152 page: selected_page, 1153 } => RhiTradeCommandV1::Reports(RhiTradePageArgsV1 { 1154 trade_id: trade_id(value)?, 1155 page: page(selected_page)?, 1156 }), 1157 }), 1158 RawCommand::Publication { command } => RhiCommandV1::Publication(match command { 1159 RawPublicationCommand::Backlog { page: value } => { 1160 RhiPublicationCommandV1::Backlog(page(value)?) 1161 } 1162 RawPublicationCommand::Targets { page: value } => { 1163 RhiPublicationCommandV1::Targets(page(value)?) 1164 } 1165 RawPublicationCommand::Retry { 1166 operation_id, 1167 workflow_id, 1168 expected_generation, 1169 } => RhiPublicationCommandV1::Retry(RhiPublicationRetryArgsV1 { 1170 operation_id: bounded_id(operation_id)?, 1171 workflow_id: bounded_id(workflow_id)?, 1172 expected_generation, 1173 }), 1174 }), 1175 RawCommand::Presence { command } => RhiCommandV1::Presence(match command { 1176 RawPresenceCommand::Desired => RhiPresenceCommandV1::Desired, 1177 RawPresenceCommand::Render { 1178 operation_id, 1179 expected_generation, 1180 } => RhiPresenceCommandV1::Render(RhiPresenceMutationArgsV1 { 1181 operation_id: bounded_id(operation_id)?, 1182 expected_generation, 1183 }), 1184 RawPresenceCommand::Refresh { 1185 operation_id, 1186 expected_generation, 1187 } => RhiPresenceCommandV1::Refresh(RhiPresenceMutationArgsV1 { 1188 operation_id: bounded_id(operation_id)?, 1189 expected_generation, 1190 }), 1191 }), 1192 RawCommand::Doctor => RhiCommandV1::Doctor, 1193 }) 1194 } 1195 1196 fn is_lower_hex(value: &str, length: usize) -> bool { 1197 value.len() == length 1198 && value 1199 .bytes() 1200 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 1201 } 1202 1203 #[cfg(test)] 1204 mod tests { 1205 use super::*; 1206 1207 fn parse(command: &[&str]) -> Result<RhiCliInvocationV1, RhiCliV1Error> { 1208 let mut arguments = vec!["rhi", "--profile", "service-host", "--instance", "default"]; 1209 arguments.extend_from_slice(command); 1210 parse_rhi_cli_v1_from(arguments) 1211 } 1212 1213 #[test] 1214 fn exact_command_inventory_parses() { 1215 let trade = "00000000000000000000000000000000"; 1216 let vectors = [ 1217 vec!["run"], 1218 vec!["config", "init"], 1219 vec!["config", "validate"], 1220 vec!["config", "show"], 1221 vec!["config", "schema"], 1222 vec![ 1223 "config", 1224 "apply", 1225 "--candidate-config", 1226 "/tmp/candidate.toml", 1227 ], 1228 vec!["state", "init"], 1229 vec!["state", "status"], 1230 vec![ 1231 "state", 1232 "backup", 1233 "--operation-id", 1234 "backup-1", 1235 "--target", 1236 "/tmp/backup", 1237 "--expected-generation", 1238 "1", 1239 "--confirm", 1240 ], 1241 vec![ 1242 "state", 1243 "restore", 1244 "--manifest", 1245 "/tmp/manifest.json", 1246 "--manifest-sha256", 1247 "0000000000000000000000000000000000000000000000000000000000000000", 1248 "--bundle", 1249 "/tmp/backup", 1250 "--maximum-state-bytes", 1251 "1048576", 1252 "--confirm", 1253 ], 1254 vec!["state", "verify"], 1255 vec!["state", "migrate"], 1256 vec!["identity", "init"], 1257 vec!["identity", "status"], 1258 vec!["identity", "export-public"], 1259 vec!["status"], 1260 vec!["metrics", "snapshot"], 1261 vec!["reconciliation", "status"], 1262 vec!["reconciliation", "jobs"], 1263 vec![ 1264 "reconciliation", 1265 "refresh", 1266 "--operation-id", 1267 "refresh-1", 1268 "--trade-id", 1269 trade, 1270 "--expected-dirty-generation", 1271 "1", 1272 ], 1273 vec!["sources", "list"], 1274 vec!["trade", "projection", "--trade-id", trade], 1275 vec!["trade", "report-current", "--trade-id", trade], 1276 vec!["trade", "reports", "--trade-id", trade], 1277 vec!["publication", "backlog"], 1278 vec!["publication", "targets"], 1279 vec![ 1280 "publication", 1281 "retry", 1282 "--operation-id", 1283 "retry-1", 1284 "--workflow-id", 1285 "workflow-1", 1286 "--expected-generation", 1287 "1", 1288 ], 1289 vec!["presence", "desired"], 1290 vec![ 1291 "presence", 1292 "render", 1293 "--operation-id", 1294 "render-1", 1295 "--expected-generation", 1296 "1", 1297 ], 1298 vec![ 1299 "presence", 1300 "refresh", 1301 "--operation-id", 1302 "presence-1", 1303 "--expected-generation", 1304 "1", 1305 ], 1306 vec!["doctor"], 1307 ]; 1308 for arguments in vectors { 1309 parse(&arguments).expect("command"); 1310 } 1311 } 1312 1313 #[test] 1314 fn profiles_paths_and_output_are_cross_bound() { 1315 for profile in ["service-host", "interactive"] { 1316 let invocation = parse_rhi_cli_v1_from([ 1317 "rhi", 1318 "--profile", 1319 profile, 1320 "--instance", 1321 "north-01", 1322 "--config", 1323 "/etc/radroots/rhi.toml", 1324 "--output", 1325 "json", 1326 "run", 1327 ]) 1328 .expect("host profile"); 1329 assert_eq!(invocation.instance().as_str(), "north-01"); 1330 assert_eq!( 1331 invocation.config_path(), 1332 Some(Path::new("/etc/radroots/rhi.toml")) 1333 ); 1334 assert_eq!(invocation.output_mode(), RhiCliOutputModeV1::Json); 1335 assert!(invocation.repo_local_root().is_none()); 1336 } 1337 1338 let repo_local = parse_rhi_cli_v1_from([ 1339 "rhi", 1340 "--profile", 1341 "repo-local", 1342 "--instance", 1343 "dev", 1344 "--repo-local-root", 1345 "/repo/radroots", 1346 "config", 1347 "validate", 1348 ]) 1349 .expect("repo local"); 1350 assert_eq!(repo_local.profile(), RhiBootstrapProfileV1::RepoLocal); 1351 assert_eq!( 1352 repo_local.repo_local_root(), 1353 Some(Path::new("/repo/radroots")) 1354 ); 1355 assert_eq!(repo_local.output_mode(), RhiCliOutputModeV1::Human); 1356 } 1357 1358 #[test] 1359 fn invalid_bootstrap_values_fail_with_stable_kinds() { 1360 for value in ["Upper", "north-", "north.west"] { 1361 let error = parse_rhi_cli_v1_from([ 1362 "rhi", 1363 "--profile", 1364 "service-host", 1365 "--instance", 1366 value, 1367 "run", 1368 ]) 1369 .expect_err("invalid instance"); 1370 assert_eq!(error.kind(), RhiCliV1ErrorKind::InvalidInstance); 1371 } 1372 1373 let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES); 1374 assert!( 1375 parse_rhi_cli_v1_from([ 1376 "rhi", 1377 "--profile", 1378 "service-host", 1379 "--instance", 1380 exact.as_str(), 1381 "run", 1382 ]) 1383 .is_ok() 1384 ); 1385 let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1); 1386 assert_eq!( 1387 parse_rhi_cli_v1_from([ 1388 "rhi", 1389 "--profile", 1390 "service-host", 1391 "--instance", 1392 overlong.as_str(), 1393 "run", 1394 ]) 1395 .expect_err("overlong instance") 1396 .kind(), 1397 RhiCliV1ErrorKind::InvalidInstance 1398 ); 1399 1400 assert_eq!( 1401 parse_rhi_cli_v1_from(["rhi", "--profile", "repo-local", "--instance", "dev", "run"]) 1402 .expect_err("missing root") 1403 .kind(), 1404 RhiCliV1ErrorKind::InvalidRepoLocalRoot 1405 ); 1406 assert_eq!( 1407 parse_rhi_cli_v1_from([ 1408 "rhi", 1409 "--profile", 1410 "interactive", 1411 "--instance", 1412 "dev", 1413 "--repo-local-root", 1414 "/repo/radroots", 1415 "run", 1416 ]) 1417 .expect_err("unexpected root") 1418 .kind(), 1419 RhiCliV1ErrorKind::UnexpectedRepoLocalRoot 1420 ); 1421 for invalid in ["relative", "/", "/repo/../escape"] { 1422 assert_eq!( 1423 parse_rhi_cli_v1_from([ 1424 "rhi", 1425 "--profile", 1426 "repo-local", 1427 "--instance", 1428 "dev", 1429 "--repo-local-root", 1430 invalid, 1431 "run", 1432 ]) 1433 .expect_err("invalid root") 1434 .kind(), 1435 RhiCliV1ErrorKind::InvalidRepoLocalRoot 1436 ); 1437 } 1438 for invalid in ["relative.toml", "/", "/etc/../secret.toml"] { 1439 assert_eq!( 1440 parse_rhi_cli_v1_from([ 1441 "rhi", 1442 "--profile", 1443 "service-host", 1444 "--instance", 1445 "default", 1446 "--config", 1447 invalid, 1448 "run", 1449 ]) 1450 .expect_err("invalid config") 1451 .kind(), 1452 RhiCliV1ErrorKind::InvalidConfigPath 1453 ); 1454 } 1455 } 1456 1457 #[test] 1458 fn removed_and_unknown_inputs_fail_without_sources() { 1459 for arguments in [ 1460 vec!["rhi", "run"], 1461 vec!["rhi", "--profile", "service-host", "run"], 1462 vec!["rhi", "--profile", "service-host", "--instance", "default"], 1463 vec![ 1464 "rhi", 1465 "--profile", 1466 "production", 1467 "--instance", 1468 "default", 1469 "run", 1470 ], 1471 vec![ 1472 "rhi", 1473 "--profile", 1474 "service-host", 1475 "--instance", 1476 "default", 1477 "--identity", 1478 "/secret", 1479 "run", 1480 ], 1481 vec![ 1482 "rhi", 1483 "--profile", 1484 "service-host", 1485 "--instance", 1486 "default", 1487 "--allow-generate-identity", 1488 "run", 1489 ], 1490 vec![ 1491 "rhi", 1492 "--profile", 1493 "service-host", 1494 "--instance", 1495 "default", 1496 "--logs-dir", 1497 "/tmp/logs", 1498 "run", 1499 ], 1500 vec![ 1501 "rhi", 1502 "--profile", 1503 "service-host", 1504 "--instance", 1505 "default", 1506 "--worker", 1507 "legacy", 1508 "run", 1509 ], 1510 vec![ 1511 "rhi", 1512 "--profile", 1513 "service-host", 1514 "--instance", 1515 "default", 1516 "identity", 1517 "rekey", 1518 ], 1519 vec![ 1520 "rhi", 1521 "--profile", 1522 "service-host", 1523 "--instance", 1524 "default", 1525 "identity", 1526 "replace", 1527 ], 1528 ] { 1529 let failure = parse_rhi_cli_v1_from(arguments).expect_err("arguments must fail"); 1530 assert_eq!(failure.kind(), RhiCliV1ErrorKind::InvalidArguments); 1531 assert!(Error::source(&failure).is_none()); 1532 } 1533 } 1534 1535 #[test] 1536 fn debug_and_errors_do_not_render_caller_values() { 1537 let instance = "sensitive-instance"; 1538 let config = "/sensitive/config.toml"; 1539 let invocation = parse_rhi_cli_v1_from([ 1540 "rhi", 1541 "--profile", 1542 "service-host", 1543 "--instance", 1544 instance, 1545 "--config", 1546 config, 1547 "doctor", 1548 ]) 1549 .expect("invocation"); 1550 let debug = format!("{invocation:?}"); 1551 assert!(!debug.contains(instance)); 1552 assert!(!debug.contains(config)); 1553 1554 let secret = "secret-cli-value"; 1555 let failure = 1556 parse_rhi_cli_v1_from(["rhi", "--profile", secret, "--instance", "default", "run"]) 1557 .expect_err("invalid profile"); 1558 let rendered = format!("{failure} {failure:?}"); 1559 assert!(!rendered.contains(secret)); 1560 assert!(Error::source(&failure).is_none()); 1561 } 1562 }