cli_v1.rs (36776B)
1 //! One-pass command-line admission for the hardened Myc command contract. 2 3 use std::error::Error; 4 use std::ffi::OsString; 5 use std::fmt; 6 use std::num::NonZeroU64; 7 use std::path::{Component, Path, PathBuf}; 8 9 use clap::{Parser, Subcommand, ValueEnum}; 10 use radroots_runtime_paths::InstanceId; 11 use radroots_service_host::AdminOperationId; 12 use radroots_service_sqlite::BackupManifestSha256; 13 14 /// The exact bootstrap profile selected by the operator. 15 #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] 16 #[serde(rename_all = "snake_case")] 17 pub enum MycBootstrapProfileV1 { 18 ServiceHost, 19 Interactive, 20 RepoLocal, 21 } 22 23 /// The only two governed command-result encodings. 24 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] 25 #[serde(rename_all = "snake_case")] 26 pub enum MycCliOutputModeV1 { 27 #[default] 28 Human, 29 Json, 30 } 31 32 /// The exact governed top-level Myc command inventory. 33 #[derive(PartialEq, Eq)] 34 pub enum MycCommandV1 { 35 Run, 36 Config(MycConfigCommandV1), 37 State(MycStateCommandV1), 38 Identity(MycIdentityCommandV1), 39 Status, 40 Doctor, 41 } 42 43 /// Governed configuration commands. 44 #[derive(PartialEq, Eq)] 45 pub enum MycConfigCommandV1 { 46 Init, 47 Validate, 48 Show, 49 Schema, 50 Apply(MycConfigApplyArgsV1), 51 } 52 53 /// Governed state commands. 54 #[derive(PartialEq, Eq)] 55 pub enum MycStateCommandV1 { 56 Init, 57 Status, 58 Backup(MycStateBackupArgsV1), 59 Restore(MycStateRestoreArgsV1), 60 Verify, 61 Migrate, 62 } 63 64 /// Governed identity commands. 65 #[derive(PartialEq, Eq)] 66 pub enum MycIdentityCommandV1 { 67 Init(MycIdentityCommandArgsV1), 68 Status(MycIdentityCommandArgsV1), 69 ExportPublic(MycIdentityCommandArgsV1), 70 } 71 72 /// Exact offline configuration-apply input. 73 #[derive(PartialEq, Eq)] 74 pub struct MycConfigApplyArgsV1 { 75 candidate_config: PathBuf, 76 } 77 78 impl MycConfigApplyArgsV1 { 79 #[must_use] 80 pub fn candidate_config(&self) -> &Path { 81 &self.candidate_config 82 } 83 } 84 85 impl fmt::Debug for MycConfigApplyArgsV1 { 86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 87 formatter.write_str("MycConfigApplyArgsV1([redacted])") 88 } 89 } 90 91 /// Exact online-or-offline state-backup input. 92 #[derive(PartialEq, Eq)] 93 pub struct MycStateBackupArgsV1 { 94 operation_id: Box<str>, 95 target: PathBuf, 96 expected_generation: u64, 97 } 98 99 impl MycStateBackupArgsV1 { 100 #[must_use] 101 pub fn operation_id(&self) -> &str { 102 &self.operation_id 103 } 104 105 #[must_use] 106 pub fn target(&self) -> &Path { 107 &self.target 108 } 109 110 #[must_use] 111 pub const fn expected_generation(&self) -> u64 { 112 self.expected_generation 113 } 114 } 115 116 impl fmt::Debug for MycStateBackupArgsV1 { 117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 118 formatter 119 .debug_struct("MycStateBackupArgsV1") 120 .field("operation_id", &"[redacted]") 121 .field("target", &"[redacted]") 122 .field("expected_generation", &self.expected_generation) 123 .finish() 124 } 125 } 126 127 /// Exact offline restore-verification input. 128 #[derive(PartialEq, Eq)] 129 pub struct MycStateRestoreArgsV1 { 130 manifest: PathBuf, 131 manifest_sha256: BackupManifestSha256, 132 bundle: PathBuf, 133 maximum_state_bytes: NonZeroU64, 134 } 135 136 impl MycStateRestoreArgsV1 { 137 #[must_use] 138 pub fn manifest(&self) -> &Path { 139 &self.manifest 140 } 141 142 #[must_use] 143 pub const fn manifest_sha256(&self) -> BackupManifestSha256 { 144 self.manifest_sha256 145 } 146 147 #[must_use] 148 pub fn bundle(&self) -> &Path { 149 &self.bundle 150 } 151 152 #[must_use] 153 pub const fn maximum_state_bytes(&self) -> NonZeroU64 { 154 self.maximum_state_bytes 155 } 156 } 157 158 impl fmt::Debug for MycStateRestoreArgsV1 { 159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 160 formatter.write_str("MycStateRestoreArgsV1([redacted])") 161 } 162 } 163 164 /// Role input required by every identity command. 165 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 166 pub struct MycIdentityCommandArgsV1 { 167 role: crate::MycProviderRole, 168 } 169 170 impl MycIdentityCommandArgsV1 { 171 #[must_use] 172 pub const fn role(self) -> crate::MycProviderRole { 173 self.role 174 } 175 } 176 177 impl fmt::Debug for MycCommandV1 { 178 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 179 formatter.write_str(match self { 180 Self::Run => "MycCommandV1::Run", 181 Self::Config(_) => "MycCommandV1::Config([redacted])", 182 Self::State(_) => "MycCommandV1::State([redacted])", 183 Self::Identity(_) => "MycCommandV1::Identity([redacted])", 184 Self::Status => "MycCommandV1::Status", 185 Self::Doctor => "MycCommandV1::Doctor", 186 }) 187 } 188 } 189 190 /// The only three process authorities selected by the hardened CLI. 191 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 192 pub enum MycCliPrimaryAuthorityV1 { 193 Daemon, 194 Offline, 195 LiveUnixAdmin, 196 } 197 198 /// The closed offline operation classes selected before any state access. 199 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 200 pub enum MycCliOfflineOperationV1 { 201 Config, 202 StateExclusive, 203 StateReadOnly, 204 IdentityExclusive, 205 IdentityReadOnly, 206 Doctor, 207 } 208 209 /// The closed Unix-admin operations reachable from the command inventory. 210 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 211 pub enum MycCliAdminOperationV1 { 212 Status, 213 StateStatus, 214 StateBackup, 215 IdentityStatus, 216 IdentityPublic, 217 } 218 219 #[cfg(any(target_os = "linux", target_os = "macos"))] 220 impl MycCliAdminOperationV1 { 221 /// Returns the exact native Unix-admin route selected by this operation. 222 #[must_use] 223 pub const fn route(self) -> crate::MycAdminRoute { 224 match self { 225 Self::Status => crate::MycAdminRoute::Status, 226 Self::StateStatus => crate::MycAdminRoute::StateStatus, 227 Self::StateBackup => crate::MycAdminRoute::StateBackup, 228 Self::IdentityStatus => crate::MycAdminRoute::IdentityStatus, 229 Self::IdentityPublic => crate::MycAdminRoute::IdentityPublic, 230 } 231 } 232 } 233 234 /// A sealed, side-effect-free execution plan for one admitted CLI invocation. 235 /// 236 /// Construction is owned by [`plan_myc_cli_v1`]. A live mutation never carries 237 /// an offline fallback, while explicitly read-only status, backup, and public 238 /// identity operations may fall back only after later execution proves the 239 /// daemon writer lock is free. 240 /// 241 /// ```compile_fail 242 /// use myc::{MycCliExecutionPlanV1, MycCliPrimaryAuthorityV1}; 243 /// 244 /// let _ = MycCliExecutionPlanV1 { 245 /// primary_authority: MycCliPrimaryAuthorityV1::Offline, 246 /// offline_operation: None, 247 /// admin_operation: None, 248 /// daemon_unavailable_offline_fallback: true, 249 /// }; 250 /// ``` 251 #[derive(Clone, Copy, PartialEq, Eq)] 252 pub struct MycCliExecutionPlanV1 { 253 primary_authority: MycCliPrimaryAuthorityV1, 254 offline_operation: Option<MycCliOfflineOperationV1>, 255 admin_operation: Option<MycCliAdminOperationV1>, 256 daemon_unavailable_offline_fallback: bool, 257 } 258 259 impl MycCliExecutionPlanV1 { 260 /// Returns the authority that must be attempted first. 261 #[must_use] 262 pub const fn primary_authority(&self) -> MycCliPrimaryAuthorityV1 { 263 self.primary_authority 264 } 265 266 /// Returns the bounded offline operation, when the plan admits one. 267 #[must_use] 268 pub const fn offline_operation(&self) -> Option<MycCliOfflineOperationV1> { 269 self.offline_operation 270 } 271 272 /// Returns the bounded Unix-admin operation, when the plan admits one. 273 #[must_use] 274 pub const fn admin_operation(&self) -> Option<MycCliAdminOperationV1> { 275 self.admin_operation 276 } 277 278 /// Returns whether a missing daemon may fall back to read-only offline work. 279 #[must_use] 280 pub const fn allows_daemon_unavailable_offline_fallback(&self) -> bool { 281 self.daemon_unavailable_offline_fallback 282 } 283 } 284 285 impl fmt::Debug for MycCliExecutionPlanV1 { 286 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 287 formatter 288 .debug_struct("MycCliExecutionPlanV1") 289 .field("primary_authority", &self.primary_authority) 290 .field("offline_operation", &self.offline_operation) 291 .field("admin_operation", &self.admin_operation) 292 .field( 293 "daemon_unavailable_offline_fallback", 294 &self.daemon_unavailable_offline_fallback, 295 ) 296 .finish() 297 } 298 } 299 300 /// Stable source-free classification for command-line admission failures. 301 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 302 pub enum MycCliV1ErrorKind { 303 InvalidArguments, 304 InvalidInstance, 305 InvalidRepoLocalRoot, 306 UnexpectedRepoLocalRoot, 307 InvalidConfigPath, 308 InvalidCommandInput, 309 } 310 311 impl MycCliV1ErrorKind { 312 const fn message(self) -> &'static str { 313 match self { 314 Self::InvalidArguments => "command-line arguments are invalid", 315 Self::InvalidInstance => "instance identifier is invalid", 316 Self::InvalidRepoLocalRoot => "repo-local profile requires a valid absolute root", 317 Self::UnexpectedRepoLocalRoot => { 318 "repo-local root is forbidden outside the repo-local profile" 319 } 320 Self::InvalidConfigPath => "configuration path must be absolute without traversal", 321 Self::InvalidCommandInput => "command input is invalid", 322 } 323 } 324 } 325 326 /// One safe command-line admission failure. 327 #[derive(Clone, Copy, PartialEq, Eq)] 328 pub struct MycCliV1Error { 329 kind: MycCliV1ErrorKind, 330 } 331 332 impl MycCliV1Error { 333 const fn new(kind: MycCliV1ErrorKind) -> Self { 334 Self { kind } 335 } 336 337 /// Returns the stable failure classification. 338 #[must_use] 339 pub const fn kind(self) -> MycCliV1ErrorKind { 340 self.kind 341 } 342 } 343 344 impl fmt::Debug for MycCliV1Error { 345 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 346 formatter 347 .debug_struct("MycCliV1Error") 348 .field("kind", &self.kind) 349 .finish() 350 } 351 } 352 353 impl fmt::Display for MycCliV1Error { 354 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 355 formatter.write_str(self.kind.message()) 356 } 357 } 358 359 impl Error for MycCliV1Error {} 360 361 /// A validated one-pass Myc bootstrap and command selection. 362 pub struct MycCliInvocationV1 { 363 profile: MycBootstrapProfileV1, 364 instance: InstanceId, 365 repo_local_root: Option<PathBuf>, 366 config_path: Option<PathBuf>, 367 output_mode: MycCliOutputModeV1, 368 command: MycCommandV1, 369 } 370 371 impl MycCliInvocationV1 { 372 /// Returns the explicitly selected bootstrap profile. 373 #[must_use] 374 pub const fn profile(&self) -> MycBootstrapProfileV1 { 375 self.profile 376 } 377 378 /// Returns the validated instance identifier. 379 #[must_use] 380 pub fn instance(&self) -> &InstanceId { 381 &self.instance 382 } 383 384 /// Returns the explicit repo-local root, when selected. 385 #[must_use] 386 pub fn repo_local_root(&self) -> Option<&Path> { 387 self.repo_local_root.as_deref() 388 } 389 390 /// Returns the optional explicit configuration path. 391 #[must_use] 392 pub fn config_path(&self) -> Option<&Path> { 393 self.config_path.as_deref() 394 } 395 396 /// Returns the exact result encoding selected once at admission. 397 #[must_use] 398 pub const fn output_mode(&self) -> MycCliOutputModeV1 { 399 self.output_mode 400 } 401 402 /// Returns the exact governed command selection. 403 #[must_use] 404 pub const fn command(&self) -> &MycCommandV1 { 405 &self.command 406 } 407 } 408 409 impl fmt::Debug for MycCliInvocationV1 { 410 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 411 formatter 412 .debug_struct("MycCliInvocationV1") 413 .field("profile", &self.profile) 414 .field("instance", &"[redacted]") 415 .field( 416 "repo_local_root", 417 &self.repo_local_root.as_ref().map(|_| "[redacted]"), 418 ) 419 .field( 420 "config_path", 421 &self.config_path.as_ref().map(|_| "[redacted]"), 422 ) 423 .field("output_mode", &self.output_mode) 424 .field("command", &self.command) 425 .finish() 426 } 427 } 428 429 /// Parses the exact hardened Myc bootstrap and command tree once. 430 /// 431 /// The iterator must include the program name as its first element. Clap's 432 /// dependency-owned diagnostic is deliberately discarded so caller-controlled 433 /// argument text cannot escape through this crate's stable error boundary. 434 pub fn parse_myc_cli_v1_from<I, T>(arguments: I) -> Result<MycCliInvocationV1, MycCliV1Error> 435 where 436 I: IntoIterator<Item = T>, 437 T: Into<OsString> + Clone, 438 { 439 let parsed = RawMycCliV1::try_parse_from(arguments) 440 .map_err(|_| MycCliV1Error::new(MycCliV1ErrorKind::InvalidArguments))?; 441 let profile = parsed 442 .profile 443 .ok_or_else(|| MycCliV1Error::new(MycCliV1ErrorKind::InvalidArguments))? 444 .into(); 445 let instance = parsed 446 .instance 447 .ok_or_else(|| MycCliV1Error::new(MycCliV1ErrorKind::InvalidArguments))?; 448 let instance = InstanceId::new(instance) 449 .map_err(|_| MycCliV1Error::new(MycCliV1ErrorKind::InvalidInstance))?; 450 validate_bootstrap_paths( 451 profile, 452 parsed.repo_local_root.as_deref(), 453 parsed.config.as_deref(), 454 )?; 455 456 Ok(MycCliInvocationV1 { 457 profile, 458 instance, 459 repo_local_root: parsed.repo_local_root, 460 config_path: parsed.config, 461 output_mode: parsed.output.into(), 462 command: admit_command(parsed.command)?, 463 }) 464 } 465 466 /// Selects the sole permitted execution authority for an admitted command. 467 /// 468 /// This function performs no filesystem, database, socket, environment, task, 469 /// or process work. Later executors consume the plan without reparsing process 470 /// arguments. In particular, no live command receives direct SQLite authority. 471 #[must_use] 472 pub const fn plan_myc_cli_v1(invocation: &MycCliInvocationV1) -> MycCliExecutionPlanV1 { 473 match &invocation.command { 474 MycCommandV1::Run => daemon_plan(), 475 MycCommandV1::Config(_) => offline_plan(MycCliOfflineOperationV1::Config), 476 MycCommandV1::State(MycStateCommandV1::Init) 477 | MycCommandV1::State(MycStateCommandV1::Restore(_)) 478 | MycCommandV1::State(MycStateCommandV1::Verify) 479 | MycCommandV1::State(MycStateCommandV1::Migrate) => { 480 offline_plan(MycCliOfflineOperationV1::StateExclusive) 481 } 482 MycCommandV1::State(MycStateCommandV1::Status) => read_only_admin_plan( 483 MycCliAdminOperationV1::StateStatus, 484 MycCliOfflineOperationV1::StateReadOnly, 485 ), 486 MycCommandV1::State(MycStateCommandV1::Backup(_)) => read_only_admin_plan( 487 MycCliAdminOperationV1::StateBackup, 488 MycCliOfflineOperationV1::StateReadOnly, 489 ), 490 MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => { 491 offline_plan(MycCliOfflineOperationV1::IdentityExclusive) 492 } 493 MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => read_only_admin_plan( 494 MycCliAdminOperationV1::IdentityStatus, 495 MycCliOfflineOperationV1::IdentityReadOnly, 496 ), 497 MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => read_only_admin_plan( 498 MycCliAdminOperationV1::IdentityPublic, 499 MycCliOfflineOperationV1::IdentityReadOnly, 500 ), 501 MycCommandV1::Status => read_only_admin_plan( 502 MycCliAdminOperationV1::Status, 503 MycCliOfflineOperationV1::StateReadOnly, 504 ), 505 MycCommandV1::Doctor => offline_plan(MycCliOfflineOperationV1::Doctor), 506 } 507 } 508 509 const fn daemon_plan() -> MycCliExecutionPlanV1 { 510 MycCliExecutionPlanV1 { 511 primary_authority: MycCliPrimaryAuthorityV1::Daemon, 512 offline_operation: None, 513 admin_operation: None, 514 daemon_unavailable_offline_fallback: false, 515 } 516 } 517 518 const fn offline_plan(operation: MycCliOfflineOperationV1) -> MycCliExecutionPlanV1 { 519 MycCliExecutionPlanV1 { 520 primary_authority: MycCliPrimaryAuthorityV1::Offline, 521 offline_operation: Some(operation), 522 admin_operation: None, 523 daemon_unavailable_offline_fallback: false, 524 } 525 } 526 527 const fn read_only_admin_plan( 528 admin_operation: MycCliAdminOperationV1, 529 offline_operation: MycCliOfflineOperationV1, 530 ) -> MycCliExecutionPlanV1 { 531 MycCliExecutionPlanV1 { 532 primary_authority: MycCliPrimaryAuthorityV1::LiveUnixAdmin, 533 offline_operation: Some(offline_operation), 534 admin_operation: Some(admin_operation), 535 daemon_unavailable_offline_fallback: true, 536 } 537 } 538 539 fn validate_bootstrap_paths( 540 profile: MycBootstrapProfileV1, 541 repo_local_root: Option<&Path>, 542 config_path: Option<&Path>, 543 ) -> Result<(), MycCliV1Error> { 544 match (profile, repo_local_root) { 545 (MycBootstrapProfileV1::RepoLocal, Some(root)) if valid_absolute_path(root, true) => {} 546 (MycBootstrapProfileV1::RepoLocal, _) => { 547 return Err(MycCliV1Error::new(MycCliV1ErrorKind::InvalidRepoLocalRoot)); 548 } 549 (_, Some(_)) => { 550 return Err(MycCliV1Error::new( 551 MycCliV1ErrorKind::UnexpectedRepoLocalRoot, 552 )); 553 } 554 (_, None) => {} 555 } 556 557 if config_path.is_some_and(|path| !valid_absolute_path(path, true)) { 558 return Err(MycCliV1Error::new(MycCliV1ErrorKind::InvalidConfigPath)); 559 } 560 Ok(()) 561 } 562 563 fn valid_absolute_path(path: &Path, require_non_root: bool) -> bool { 564 path.is_absolute() 565 && (!require_non_root || path.parent().is_some()) 566 && path 567 .to_str() 568 .is_some_and(|value| !value.is_empty() && value.len() <= 4_096) 569 && !path 570 .components() 571 .any(|component| matches!(component, Component::ParentDir)) 572 } 573 574 #[derive(Parser)] 575 #[command(name = "myc", disable_help_subcommand = true)] 576 struct RawMycCliV1 { 577 #[arg(long, global = true, value_enum)] 578 profile: Option<RawProfile>, 579 #[arg(long, global = true)] 580 instance: Option<String>, 581 #[arg(long = "repo-local-root", global = true)] 582 repo_local_root: Option<PathBuf>, 583 #[arg(long, global = true)] 584 config: Option<PathBuf>, 585 #[arg(long, global = true, value_enum, default_value_t = RawOutputMode::Human)] 586 output: RawOutputMode, 587 #[command(subcommand)] 588 command: RawCommand, 589 } 590 591 #[derive(Clone, Copy, ValueEnum)] 592 enum RawProfile { 593 ServiceHost, 594 Interactive, 595 RepoLocal, 596 } 597 598 #[derive(Clone, Copy, Default, ValueEnum)] 599 enum RawOutputMode { 600 #[default] 601 Human, 602 Json, 603 } 604 605 impl From<RawOutputMode> for MycCliOutputModeV1 { 606 fn from(value: RawOutputMode) -> Self { 607 match value { 608 RawOutputMode::Human => Self::Human, 609 RawOutputMode::Json => Self::Json, 610 } 611 } 612 } 613 614 impl From<RawProfile> for MycBootstrapProfileV1 { 615 fn from(value: RawProfile) -> Self { 616 match value { 617 RawProfile::ServiceHost => Self::ServiceHost, 618 RawProfile::Interactive => Self::Interactive, 619 RawProfile::RepoLocal => Self::RepoLocal, 620 } 621 } 622 } 623 624 #[derive(Subcommand)] 625 enum RawCommand { 626 Run, 627 Config { 628 #[command(subcommand)] 629 command: RawConfigCommand, 630 }, 631 State { 632 #[command(subcommand)] 633 command: RawStateCommand, 634 }, 635 Identity { 636 #[command(subcommand)] 637 command: RawIdentityCommand, 638 }, 639 Status, 640 Doctor, 641 } 642 643 #[derive(Subcommand)] 644 enum RawConfigCommand { 645 Init, 646 Validate, 647 Show, 648 Schema, 649 Apply { 650 #[arg(long = "candidate-config")] 651 candidate_config: PathBuf, 652 }, 653 } 654 655 #[derive(Subcommand)] 656 enum RawStateCommand { 657 Init, 658 Status, 659 Backup { 660 #[arg(long = "operation-id")] 661 operation_id: String, 662 #[arg(long)] 663 target: PathBuf, 664 #[arg(long = "expected-generation")] 665 expected_generation: u64, 666 #[arg(long, required = true)] 667 confirm: bool, 668 }, 669 Restore { 670 #[arg(long)] 671 manifest: PathBuf, 672 #[arg(long = "manifest-sha256")] 673 manifest_sha256: String, 674 #[arg(long)] 675 bundle: PathBuf, 676 #[arg(long = "maximum-state-bytes")] 677 maximum_state_bytes: u64, 678 #[arg(long, required = true)] 679 confirm: bool, 680 }, 681 Verify, 682 Migrate, 683 } 684 685 #[derive(Subcommand)] 686 enum RawIdentityCommand { 687 Init { 688 #[arg(long, value_enum)] 689 role: RawIdentityRole, 690 }, 691 Status { 692 #[arg(long, value_enum)] 693 role: RawIdentityRole, 694 }, 695 ExportPublic { 696 #[arg(long, value_enum)] 697 role: RawIdentityRole, 698 }, 699 } 700 701 #[derive(Clone, Copy, ValueEnum)] 702 enum RawIdentityRole { 703 Transport, 704 User, 705 Discovery, 706 } 707 708 impl From<RawIdentityRole> for crate::MycProviderRole { 709 fn from(value: RawIdentityRole) -> Self { 710 match value { 711 RawIdentityRole::Transport => Self::Transport, 712 RawIdentityRole::User => Self::User, 713 RawIdentityRole::Discovery => Self::Discovery, 714 } 715 } 716 } 717 718 fn admit_command(command: RawCommand) -> Result<MycCommandV1, MycCliV1Error> { 719 let invalid = || MycCliV1Error::new(MycCliV1ErrorKind::InvalidCommandInput); 720 Ok(match command { 721 RawCommand::Run => MycCommandV1::Run, 722 RawCommand::Config { command } => MycCommandV1::Config(match command { 723 RawConfigCommand::Init => MycConfigCommandV1::Init, 724 RawConfigCommand::Validate => MycConfigCommandV1::Validate, 725 RawConfigCommand::Show => MycConfigCommandV1::Show, 726 RawConfigCommand::Schema => MycConfigCommandV1::Schema, 727 RawConfigCommand::Apply { candidate_config } => { 728 if !valid_absolute_path(&candidate_config, true) { 729 return Err(invalid()); 730 } 731 MycConfigCommandV1::Apply(MycConfigApplyArgsV1 { candidate_config }) 732 } 733 }), 734 RawCommand::State { command } => MycCommandV1::State(match command { 735 RawStateCommand::Init => MycStateCommandV1::Init, 736 RawStateCommand::Status => MycStateCommandV1::Status, 737 RawStateCommand::Backup { 738 operation_id, 739 target, 740 expected_generation, 741 confirm, 742 } => { 743 if !confirm || !valid_absolute_path(&target, true) { 744 return Err(invalid()); 745 } 746 let operation_id = AdminOperationId::new(operation_id).map_err(|_| invalid())?; 747 MycStateCommandV1::Backup(MycStateBackupArgsV1 { 748 operation_id: operation_id.as_str().into(), 749 target, 750 expected_generation, 751 }) 752 } 753 RawStateCommand::Restore { 754 manifest, 755 manifest_sha256, 756 bundle, 757 maximum_state_bytes, 758 confirm, 759 } => { 760 if !confirm 761 || !valid_absolute_path(&manifest, true) 762 || !valid_absolute_path(&bundle, true) 763 { 764 return Err(invalid()); 765 } 766 if manifest_sha256.len() != 64 767 || manifest_sha256 768 .bytes() 769 .any(|byte| !matches!(byte, b'0'..=b'9' | b'a'..=b'f')) 770 { 771 return Err(invalid()); 772 } 773 let mut digest = [0_u8; 32]; 774 hex::decode_to_slice(manifest_sha256, &mut digest).map_err(|_| invalid())?; 775 let maximum_state_bytes = 776 NonZeroU64::new(maximum_state_bytes).ok_or_else(invalid)?; 777 MycStateCommandV1::Restore(MycStateRestoreArgsV1 { 778 manifest, 779 manifest_sha256: BackupManifestSha256::from_bytes(digest), 780 bundle, 781 maximum_state_bytes, 782 }) 783 } 784 RawStateCommand::Verify => MycStateCommandV1::Verify, 785 RawStateCommand::Migrate => MycStateCommandV1::Migrate, 786 }), 787 RawCommand::Identity { command } => MycCommandV1::Identity(match command { 788 RawIdentityCommand::Init { role } => { 789 MycIdentityCommandV1::Init(MycIdentityCommandArgsV1 { role: role.into() }) 790 } 791 RawIdentityCommand::Status { role } => { 792 MycIdentityCommandV1::Status(MycIdentityCommandArgsV1 { role: role.into() }) 793 } 794 RawIdentityCommand::ExportPublic { role } => { 795 MycIdentityCommandV1::ExportPublic(MycIdentityCommandArgsV1 { role: role.into() }) 796 } 797 }), 798 RawCommand::Status => MycCommandV1::Status, 799 RawCommand::Doctor => MycCommandV1::Doctor, 800 }) 801 } 802 803 #[cfg(test)] 804 mod tests { 805 use super::*; 806 807 fn parse(command: &[&str]) -> Result<MycCliInvocationV1, MycCliV1Error> { 808 let mut arguments = vec!["myc", "--profile", "service-host", "--instance", "primary"]; 809 arguments.extend_from_slice(command); 810 parse_myc_cli_v1_from(arguments) 811 } 812 813 #[test] 814 fn exact_command_inventory_parses() { 815 let vectors = [ 816 (&["run"][..], "run"), 817 (&["config", "init"][..], "config_init"), 818 (&["config", "validate"][..], "config_validate"), 819 (&["config", "show"][..], "config_show"), 820 (&["config", "schema"][..], "config_schema"), 821 ( 822 &["config", "apply", "--candidate-config", "/candidate.toml"][..], 823 "config_apply", 824 ), 825 (&["state", "init"][..], "state_init"), 826 (&["state", "status"][..], "state_status"), 827 ( 828 &[ 829 "state", 830 "backup", 831 "--operation-id", 832 "backup-01", 833 "--target", 834 "/backup/new", 835 "--expected-generation", 836 "7", 837 "--confirm", 838 ][..], 839 "state_backup", 840 ), 841 ( 842 &[ 843 "state", 844 "restore", 845 "--manifest", 846 "/backup/manifest.json", 847 "--manifest-sha256", 848 "1111111111111111111111111111111111111111111111111111111111111111", 849 "--bundle", 850 "/backup/bundle", 851 "--maximum-state-bytes", 852 "1048576", 853 "--confirm", 854 ][..], 855 "state_restore", 856 ), 857 (&["state", "verify"][..], "state_verify"), 858 (&["state", "migrate"][..], "state_migrate"), 859 ( 860 &["identity", "init", "--role", "transport"][..], 861 "identity_init", 862 ), 863 ( 864 &["identity", "status", "--role", "user"][..], 865 "identity_status", 866 ), 867 ( 868 &["identity", "export-public", "--role", "discovery"][..], 869 "identity_export_public", 870 ), 871 (&["status"][..], "status"), 872 (&["doctor"][..], "doctor"), 873 ]; 874 for (arguments, expected) in vectors { 875 assert_eq!( 876 command_name(parse(arguments).expect("command").command()), 877 expected 878 ); 879 } 880 } 881 882 fn command_name(command: &MycCommandV1) -> &'static str { 883 match command { 884 MycCommandV1::Run => "run", 885 MycCommandV1::Config(MycConfigCommandV1::Init) => "config_init", 886 MycCommandV1::Config(MycConfigCommandV1::Validate) => "config_validate", 887 MycCommandV1::Config(MycConfigCommandV1::Show) => "config_show", 888 MycCommandV1::Config(MycConfigCommandV1::Schema) => "config_schema", 889 MycCommandV1::Config(MycConfigCommandV1::Apply(_)) => "config_apply", 890 MycCommandV1::State(MycStateCommandV1::Init) => "state_init", 891 MycCommandV1::State(MycStateCommandV1::Status) => "state_status", 892 MycCommandV1::State(MycStateCommandV1::Backup(_)) => "state_backup", 893 MycCommandV1::State(MycStateCommandV1::Restore(_)) => "state_restore", 894 MycCommandV1::State(MycStateCommandV1::Verify) => "state_verify", 895 MycCommandV1::State(MycStateCommandV1::Migrate) => "state_migrate", 896 MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => "identity_init", 897 MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => "identity_status", 898 MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => { 899 "identity_export_public" 900 } 901 MycCommandV1::Status => "status", 902 MycCommandV1::Doctor => "doctor", 903 } 904 } 905 906 #[test] 907 fn profiles_and_paths_are_cross_bound() { 908 for profile in ["service-host", "interactive"] { 909 let invocation = parse_myc_cli_v1_from([ 910 "myc", 911 "--profile", 912 profile, 913 "--instance", 914 "north-01", 915 "--config", 916 "/etc/radroots/myc.toml", 917 "run", 918 ]) 919 .expect("production profile"); 920 assert_eq!(invocation.instance().as_str(), "north-01"); 921 assert_eq!( 922 invocation.config_path(), 923 Some(Path::new("/etc/radroots/myc.toml")) 924 ); 925 assert!(invocation.repo_local_root().is_none()); 926 } 927 928 let repo_local = parse_myc_cli_v1_from([ 929 "myc", 930 "--profile", 931 "repo-local", 932 "--instance", 933 "dev", 934 "--repo-local-root", 935 "/repo/radroots", 936 "config", 937 "validate", 938 ]) 939 .expect("repo local"); 940 assert_eq!(repo_local.profile(), MycBootstrapProfileV1::RepoLocal); 941 assert_eq!( 942 repo_local.repo_local_root(), 943 Some(Path::new("/repo/radroots")) 944 ); 945 } 946 947 #[test] 948 fn invalid_bootstrap_values_fail_with_stable_kinds() { 949 for value in ["Upper", "north-", "north.west"] { 950 let error = parse_myc_cli_v1_from([ 951 "myc", 952 "--profile", 953 "service-host", 954 "--instance", 955 value, 956 "run", 957 ]) 958 .expect_err("invalid instance"); 959 assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidInstance); 960 } 961 assert_eq!( 962 parse_myc_cli_v1_from([ 963 "myc", 964 "--profile", 965 "service-host", 966 "--instance=-north", 967 "run", 968 ]) 969 .expect_err("invalid instance boundary") 970 .kind(), 971 MycCliV1ErrorKind::InvalidInstance 972 ); 973 assert_eq!( 974 parse_myc_cli_v1_from(["myc", "--profile", "service-host", "--instance=", "run",]) 975 .expect_err("empty instance") 976 .kind(), 977 MycCliV1ErrorKind::InvalidInstance 978 ); 979 let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES); 980 assert!( 981 parse_myc_cli_v1_from([ 982 "myc", 983 "--profile", 984 "service-host", 985 "--instance", 986 exact.as_str(), 987 "run", 988 ]) 989 .is_ok() 990 ); 991 let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1); 992 assert_eq!( 993 parse_myc_cli_v1_from([ 994 "myc", 995 "--profile", 996 "service-host", 997 "--instance", 998 overlong.as_str(), 999 "run", 1000 ]) 1001 .expect_err("overlong instance") 1002 .kind(), 1003 MycCliV1ErrorKind::InvalidInstance 1004 ); 1005 1006 let missing_root = 1007 parse_myc_cli_v1_from(["myc", "--profile", "repo-local", "--instance", "dev", "run"]) 1008 .expect_err("missing root"); 1009 assert_eq!(missing_root.kind(), MycCliV1ErrorKind::InvalidRepoLocalRoot); 1010 1011 let unexpected_root = parse_myc_cli_v1_from([ 1012 "myc", 1013 "--profile", 1014 "interactive", 1015 "--instance", 1016 "dev", 1017 "--repo-local-root", 1018 "/repo/radroots", 1019 "run", 1020 ]) 1021 .expect_err("unexpected root"); 1022 assert_eq!( 1023 unexpected_root.kind(), 1024 MycCliV1ErrorKind::UnexpectedRepoLocalRoot 1025 ); 1026 1027 for invalid in ["relative", "/", "/repo/../escape"] { 1028 let error = parse_myc_cli_v1_from([ 1029 "myc", 1030 "--profile", 1031 "repo-local", 1032 "--instance", 1033 "dev", 1034 "--repo-local-root", 1035 invalid, 1036 "run", 1037 ]) 1038 .expect_err("invalid root"); 1039 assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidRepoLocalRoot); 1040 } 1041 1042 for invalid in ["relative.toml", "/", "/etc/../secret.toml"] { 1043 let error = parse_myc_cli_v1_from([ 1044 "myc", 1045 "--profile", 1046 "service-host", 1047 "--instance", 1048 "primary", 1049 "--config", 1050 invalid, 1051 "run", 1052 ]) 1053 .expect_err("invalid config path"); 1054 assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidConfigPath); 1055 } 1056 } 1057 1058 #[test] 1059 fn restore_digest_is_exact_lowercase_hex_before_decode() { 1060 for digest in [ 1061 "1".repeat(63), 1062 "1".repeat(65), 1063 "A".repeat(64), 1064 "g".repeat(64), 1065 "1".repeat(1_048_576), 1066 ] { 1067 let error = parse_myc_cli_v1_from([ 1068 "myc", 1069 "--profile", 1070 "service-host", 1071 "--instance", 1072 "primary", 1073 "state", 1074 "restore", 1075 "--manifest", 1076 "/backup/manifest.json", 1077 "--manifest-sha256", 1078 digest.as_str(), 1079 "--bundle", 1080 "/backup/bundle", 1081 "--maximum-state-bytes", 1082 "1048576", 1083 "--confirm", 1084 ]) 1085 .expect_err("invalid digest"); 1086 assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidCommandInput); 1087 } 1088 } 1089 1090 #[test] 1091 fn missing_unknown_and_prototype_arguments_fail_without_sources() { 1092 for arguments in [ 1093 vec!["myc", "run"], 1094 vec!["myc", "--profile", "service-host", "run"], 1095 vec!["myc", "--profile", "service-host", "--instance", "primary"], 1096 vec![ 1097 "myc", 1098 "--profile", 1099 "production", 1100 "--instance", 1101 "primary", 1102 "run", 1103 ], 1104 vec![ 1105 "myc", 1106 "--profile", 1107 "service-host", 1108 "--instance", 1109 "primary", 1110 "--env-file", 1111 "secret.env", 1112 "run", 1113 ], 1114 vec![ 1115 "myc", 1116 "--profile", 1117 "service-host", 1118 "--instance", 1119 "primary", 1120 "persistence", 1121 "backup", 1122 ], 1123 ] { 1124 let failure = parse_myc_cli_v1_from(arguments).expect_err("arguments must fail"); 1125 assert_eq!(failure.kind(), MycCliV1ErrorKind::InvalidArguments); 1126 assert!(Error::source(&failure).is_none()); 1127 } 1128 } 1129 1130 #[test] 1131 fn debug_and_errors_do_not_render_caller_values() { 1132 let instance = "sensitive-instance"; 1133 let config = "/sensitive/config.toml"; 1134 let invocation = parse_myc_cli_v1_from([ 1135 "myc", 1136 "--profile", 1137 "service-host", 1138 "--instance", 1139 instance, 1140 "--config", 1141 config, 1142 "doctor", 1143 ]) 1144 .expect("invocation"); 1145 let debug = format!("{invocation:?}"); 1146 assert!(!debug.contains(instance)); 1147 assert!(!debug.contains(config)); 1148 1149 let secret = "secret-cli-value"; 1150 let failure = 1151 parse_myc_cli_v1_from(["myc", "--profile", secret, "--instance", "primary", "run"]) 1152 .expect_err("invalid profile"); 1153 let rendered = format!("{failure} {failure:?}"); 1154 assert!(!rendered.contains(secret)); 1155 assert!(Error::source(&failure).is_none()); 1156 } 1157 }