services_hardening_cli.rs (22482B)
1 #![forbid(unsafe_code)] 2 3 use std::collections::BTreeSet; 4 use std::error::Error; 5 use std::path::Path; 6 7 use rhi::{ 8 INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliAdminOperationV1, RhiCliOfflineOperationV1, 9 RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1ErrorKind, parse_rhi_cli_v1_from, 10 plan_rhi_cli_v1, 11 }; 12 13 const CLI_SOURCE: &str = include_str!("../src/cli_v1.rs"); 14 const MAIN_SOURCE: &str = include_str!("../src/main.rs"); 15 const PROCESS_SOURCE: &str = include_str!("../src/process_v1.rs"); 16 const OPERATOR_CONTRACT: &str = 17 include_str!("../contracts/services_hardening/operator_contract.v1.json"); 18 19 fn parse(command: &[&str]) -> rhi::RhiCliInvocationV1 { 20 let mut arguments = vec!["rhi", "--profile", "service-host", "--instance", "default"]; 21 arguments.extend_from_slice(command); 22 parse_rhi_cli_v1_from(arguments).expect("governed command") 23 } 24 25 #[test] 26 fn root_api_exposes_the_complete_closed_command_inventory() { 27 let trade = "00000000000000000000000000000000"; 28 let vectors = [ 29 vec!["run"], 30 vec!["config", "init"], 31 vec!["config", "validate"], 32 vec!["config", "show"], 33 vec!["config", "schema"], 34 vec![ 35 "config", 36 "apply", 37 "--candidate-config", 38 "/tmp/candidate.toml", 39 ], 40 vec!["state", "init"], 41 vec!["state", "status"], 42 vec![ 43 "state", 44 "backup", 45 "--operation-id", 46 "backup-1", 47 "--target", 48 "/tmp/backup", 49 "--expected-generation", 50 "1", 51 "--confirm", 52 ], 53 vec![ 54 "state", 55 "restore", 56 "--manifest", 57 "/tmp/manifest.json", 58 "--manifest-sha256", 59 "0000000000000000000000000000000000000000000000000000000000000000", 60 "--bundle", 61 "/tmp/backup", 62 "--maximum-state-bytes", 63 "1048576", 64 "--confirm", 65 ], 66 vec!["state", "verify"], 67 vec!["state", "migrate"], 68 vec!["identity", "init"], 69 vec!["identity", "status"], 70 vec!["identity", "export-public"], 71 vec!["status"], 72 vec!["metrics", "snapshot"], 73 vec!["reconciliation", "status"], 74 vec!["reconciliation", "jobs"], 75 vec![ 76 "reconciliation", 77 "refresh", 78 "--operation-id", 79 "refresh-1", 80 "--trade-id", 81 trade, 82 "--expected-dirty-generation", 83 "1", 84 ], 85 vec!["sources", "list"], 86 vec!["trade", "projection", "--trade-id", trade], 87 vec!["trade", "report-current", "--trade-id", trade], 88 vec!["trade", "reports", "--trade-id", trade], 89 vec!["publication", "backlog"], 90 vec!["publication", "targets"], 91 vec![ 92 "publication", 93 "retry", 94 "--operation-id", 95 "retry-1", 96 "--workflow-id", 97 "workflow-1", 98 "--expected-generation", 99 "1", 100 ], 101 vec!["presence", "desired"], 102 vec![ 103 "presence", 104 "render", 105 "--operation-id", 106 "render-1", 107 "--expected-generation", 108 "1", 109 ], 110 vec![ 111 "presence", 112 "refresh", 113 "--operation-id", 114 "presence-1", 115 "--expected-generation", 116 "1", 117 ], 118 vec!["doctor"], 119 ]; 120 for arguments in vectors { 121 parse(&arguments); 122 } 123 } 124 125 #[test] 126 fn bootstrap_values_are_explicit_bounded_and_cross_bound() { 127 let repo = parse_rhi_cli_v1_from([ 128 "rhi", 129 "--profile", 130 "repo-local", 131 "--instance", 132 "review_01", 133 "--repo-local-root", 134 "/repo/radroots", 135 "--config", 136 "/repo/config/rhi.toml", 137 "--output", 138 "json", 139 "doctor", 140 ]) 141 .expect("repo-local invocation"); 142 assert_eq!(repo.profile(), RhiBootstrapProfileV1::RepoLocal); 143 assert_eq!(repo.instance().as_str(), "review_01"); 144 assert_eq!(repo.repo_local_root(), Some(Path::new("/repo/radroots"))); 145 assert_eq!(repo.config_path(), Some(Path::new("/repo/config/rhi.toml"))); 146 assert_eq!(repo.output_mode(), RhiCliOutputModeV1::Json); 147 148 let exact = "a".repeat(INSTANCE_ID_MAX_BYTES); 149 assert!( 150 parse_rhi_cli_v1_from([ 151 "rhi", 152 "--profile", 153 "interactive", 154 "--instance", 155 exact.as_str(), 156 "run", 157 ]) 158 .is_ok() 159 ); 160 let over = "a".repeat(INSTANCE_ID_MAX_BYTES + 1); 161 assert_eq!( 162 parse_rhi_cli_v1_from([ 163 "rhi", 164 "--profile", 165 "interactive", 166 "--instance", 167 over.as_str(), 168 "run", 169 ]) 170 .expect_err("overlong instance") 171 .kind(), 172 RhiCliV1ErrorKind::InvalidInstance 173 ); 174 175 let exact_path = format!("/{}", "a".repeat(4_095)); 176 assert_eq!(exact_path.len(), 4_096); 177 assert!( 178 parse_rhi_cli_v1_from([ 179 "rhi", 180 "--profile", 181 "interactive", 182 "--instance", 183 "default", 184 "--config", 185 exact_path.as_str(), 186 "run", 187 ]) 188 .is_ok() 189 ); 190 let overlong_path = format!("{exact_path}a"); 191 assert_eq!( 192 parse_rhi_cli_v1_from([ 193 "rhi", 194 "--profile", 195 "interactive", 196 "--instance", 197 "default", 198 "--config", 199 overlong_path.as_str(), 200 "run", 201 ]) 202 .expect_err("overlong path") 203 .kind(), 204 RhiCliV1ErrorKind::InvalidConfigPath 205 ); 206 } 207 208 #[test] 209 fn every_command_has_one_exact_nonforgeable_execution_plan() { 210 let vectors = [ 211 ("run", vec!["run"], "daemon", None, None), 212 ( 213 "config init", 214 vec!["config", "init"], 215 "offline", 216 Some("config"), 217 None, 218 ), 219 ( 220 "config validate", 221 vec!["config", "validate"], 222 "offline", 223 Some("config"), 224 None, 225 ), 226 ( 227 "config show", 228 vec!["config", "show"], 229 "live_unix_admin", 230 None, 231 Some("/v1/config/effective"), 232 ), 233 ( 234 "config schema", 235 vec!["config", "schema"], 236 "offline", 237 Some("config"), 238 None, 239 ), 240 ( 241 "config apply", 242 vec![ 243 "config", 244 "apply", 245 "--candidate-config", 246 "/tmp/candidate.toml", 247 ], 248 "offline", 249 Some("config"), 250 None, 251 ), 252 ( 253 "state init", 254 vec!["state", "init"], 255 "offline", 256 Some("state_exclusive"), 257 None, 258 ), 259 ( 260 "state status", 261 vec!["state", "status"], 262 "live_unix_admin", 263 None, 264 Some("/v1/state/status"), 265 ), 266 ( 267 "state backup", 268 vec![ 269 "state", 270 "backup", 271 "--operation-id", 272 "backup-1", 273 "--target", 274 "/tmp/backup", 275 "--expected-generation", 276 "1", 277 "--confirm", 278 ], 279 "live_unix_admin", 280 None, 281 Some("/v1/state/backup"), 282 ), 283 ( 284 "state restore", 285 vec![ 286 "state", 287 "restore", 288 "--manifest", 289 "/tmp/manifest.json", 290 "--manifest-sha256", 291 "0000000000000000000000000000000000000000000000000000000000000000", 292 "--bundle", 293 "/tmp/backup", 294 "--maximum-state-bytes", 295 "1048576", 296 "--confirm", 297 ], 298 "offline", 299 Some("state_exclusive"), 300 None, 301 ), 302 ( 303 "state verify", 304 vec!["state", "verify"], 305 "offline", 306 Some("state_exclusive"), 307 None, 308 ), 309 ( 310 "state migrate", 311 vec!["state", "migrate"], 312 "offline", 313 Some("state_exclusive"), 314 None, 315 ), 316 ( 317 "identity init", 318 vec!["identity", "init"], 319 "offline", 320 Some("identity_exclusive"), 321 None, 322 ), 323 ( 324 "identity status", 325 vec!["identity", "status"], 326 "live_unix_admin", 327 None, 328 Some("/v1/identity/status"), 329 ), 330 ( 331 "identity export-public", 332 vec!["identity", "export-public"], 333 "live_unix_admin", 334 None, 335 Some("/v1/identity/public"), 336 ), 337 ( 338 "status", 339 vec!["status"], 340 "live_unix_admin", 341 None, 342 Some("/v1/status"), 343 ), 344 ( 345 "metrics snapshot", 346 vec!["metrics", "snapshot"], 347 "live_unix_admin", 348 None, 349 Some("/v1/metrics/snapshot"), 350 ), 351 ( 352 "reconciliation status", 353 vec!["reconciliation", "status"], 354 "live_unix_admin", 355 None, 356 Some("/v1/reconciliation/status"), 357 ), 358 ( 359 "reconciliation jobs", 360 vec!["reconciliation", "jobs"], 361 "live_unix_admin", 362 None, 363 Some("/v1/reconciliation/jobs"), 364 ), 365 ( 366 "reconciliation refresh", 367 vec![ 368 "reconciliation", 369 "refresh", 370 "--operation-id", 371 "refresh-1", 372 "--trade-id", 373 "00000000000000000000000000000000", 374 "--expected-dirty-generation", 375 "1", 376 ], 377 "live_unix_admin", 378 None, 379 Some("/v1/reconciliation/refresh"), 380 ), 381 ( 382 "sources list", 383 vec!["sources", "list"], 384 "live_unix_admin", 385 None, 386 Some("/v1/sources"), 387 ), 388 ( 389 "trade projection", 390 vec![ 391 "trade", 392 "projection", 393 "--trade-id", 394 "00000000000000000000000000000000", 395 ], 396 "live_unix_admin", 397 None, 398 Some("/v1/trades/{trade_id}/projection"), 399 ), 400 ( 401 "trade report-current", 402 vec![ 403 "trade", 404 "report-current", 405 "--trade-id", 406 "00000000000000000000000000000000", 407 ], 408 "live_unix_admin", 409 None, 410 Some("/v1/trades/{trade_id}/reports/current"), 411 ), 412 ( 413 "trade reports", 414 vec![ 415 "trade", 416 "reports", 417 "--trade-id", 418 "00000000000000000000000000000000", 419 ], 420 "live_unix_admin", 421 None, 422 Some("/v1/trades/{trade_id}/reports"), 423 ), 424 ( 425 "publication backlog", 426 vec!["publication", "backlog"], 427 "live_unix_admin", 428 None, 429 Some("/v1/publication/backlog"), 430 ), 431 ( 432 "publication targets", 433 vec!["publication", "targets"], 434 "live_unix_admin", 435 None, 436 Some("/v1/publication/targets"), 437 ), 438 ( 439 "publication retry", 440 vec![ 441 "publication", 442 "retry", 443 "--operation-id", 444 "retry-1", 445 "--workflow-id", 446 "workflow-1", 447 "--expected-generation", 448 "1", 449 ], 450 "live_unix_admin", 451 None, 452 Some("/v1/publication/retry"), 453 ), 454 ( 455 "presence desired", 456 vec!["presence", "desired"], 457 "live_unix_admin", 458 None, 459 Some("/v1/presence/desired"), 460 ), 461 ( 462 "presence render", 463 vec![ 464 "presence", 465 "render", 466 "--operation-id", 467 "render-1", 468 "--expected-generation", 469 "1", 470 ], 471 "live_unix_admin", 472 None, 473 Some("/v1/presence/render"), 474 ), 475 ( 476 "presence refresh", 477 vec![ 478 "presence", 479 "refresh", 480 "--operation-id", 481 "presence-1", 482 "--expected-generation", 483 "1", 484 ], 485 "live_unix_admin", 486 None, 487 Some("/v1/presence/refresh"), 488 ), 489 ("doctor", vec!["doctor"], "offline", Some("doctor"), None), 490 ]; 491 let contract: serde_json::Value = 492 serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract"); 493 let dispatch = contract 494 .get("cli_dispatch") 495 .and_then(serde_json::Value::as_object) 496 .expect("CLI dispatch contract"); 497 assert_eq!( 498 dispatch.keys().map(String::as_str).collect::<BTreeSet<_>>(), 499 BTreeSet::from([ 500 "commands", 501 "live_command_offline_fallback", 502 "live_direct_sqlite_access", 503 "parse_count", 504 "primary_authorities", 505 ]) 506 ); 507 assert_eq!(dispatch["parse_count"], 1); 508 assert_eq!( 509 dispatch["primary_authorities"], 510 serde_json::json!(["daemon", "offline", "live_unix_admin"]) 511 ); 512 assert_eq!(dispatch["live_direct_sqlite_access"], false); 513 assert_eq!(dispatch["live_command_offline_fallback"], false); 514 let commands = dispatch["commands"].as_array().expect("command inventory"); 515 assert_eq!(commands.len(), vectors.len()); 516 517 for (index, (command, arguments, authority, offline, route)) in vectors.into_iter().enumerate() 518 { 519 let invocation = parse(&arguments); 520 let plan = plan_rhi_cli_v1(&invocation); 521 assert_eq!(authority_name(plan.primary_authority()), authority); 522 assert_eq!(plan.offline_operation().map(offline_name), offline); 523 assert_eq!(plan.admin_operation().map(admin_path), route); 524 525 let row = commands[index].as_object().expect("command row"); 526 let mut expected_keys = BTreeSet::from(["command", "primary_authority"]); 527 if offline.is_some() { 528 expected_keys.insert("offline_operation"); 529 } 530 if route.is_some() { 531 expected_keys.insert("admin_route"); 532 } 533 assert_eq!( 534 row.keys().map(String::as_str).collect::<BTreeSet<_>>(), 535 expected_keys 536 ); 537 assert_eq!(row["command"], command); 538 assert_eq!(row["primary_authority"], authority); 539 assert_eq!( 540 row.get("offline_operation") 541 .and_then(serde_json::Value::as_str), 542 offline 543 ); 544 assert_eq!( 545 row.get("admin_route").and_then(serde_json::Value::as_str), 546 route 547 ); 548 } 549 } 550 551 fn authority_name(authority: RhiCliPrimaryAuthorityV1) -> &'static str { 552 match authority { 553 RhiCliPrimaryAuthorityV1::Daemon => "daemon", 554 RhiCliPrimaryAuthorityV1::Offline => "offline", 555 RhiCliPrimaryAuthorityV1::LiveUnixAdmin => "live_unix_admin", 556 } 557 } 558 559 fn offline_name(operation: RhiCliOfflineOperationV1) -> &'static str { 560 match operation { 561 RhiCliOfflineOperationV1::Config => "config", 562 RhiCliOfflineOperationV1::StateExclusive => "state_exclusive", 563 RhiCliOfflineOperationV1::IdentityExclusive => "identity_exclusive", 564 RhiCliOfflineOperationV1::Doctor => "doctor", 565 } 566 } 567 568 fn admin_path(operation: RhiCliAdminOperationV1) -> &'static str { 569 match operation { 570 RhiCliAdminOperationV1::Status => "/v1/status", 571 RhiCliAdminOperationV1::EffectiveConfig => "/v1/config/effective", 572 RhiCliAdminOperationV1::IdentityStatus => "/v1/identity/status", 573 RhiCliAdminOperationV1::IdentityPublic => "/v1/identity/public", 574 RhiCliAdminOperationV1::StateStatus => "/v1/state/status", 575 RhiCliAdminOperationV1::StateBackup => "/v1/state/backup", 576 RhiCliAdminOperationV1::MetricsSnapshot => "/v1/metrics/snapshot", 577 RhiCliAdminOperationV1::ReconciliationStatus => "/v1/reconciliation/status", 578 RhiCliAdminOperationV1::ReconciliationJobs => "/v1/reconciliation/jobs", 579 RhiCliAdminOperationV1::ReconciliationRefresh => "/v1/reconciliation/refresh", 580 RhiCliAdminOperationV1::Sources => "/v1/sources", 581 RhiCliAdminOperationV1::TradeProjection => "/v1/trades/{trade_id}/projection", 582 RhiCliAdminOperationV1::TradeReportCurrent => "/v1/trades/{trade_id}/reports/current", 583 RhiCliAdminOperationV1::TradeReports => "/v1/trades/{trade_id}/reports", 584 RhiCliAdminOperationV1::PublicationBacklog => "/v1/publication/backlog", 585 RhiCliAdminOperationV1::PublicationTargets => "/v1/publication/targets", 586 RhiCliAdminOperationV1::PublicationRetry => "/v1/publication/retry", 587 RhiCliAdminOperationV1::PresenceDesired => "/v1/presence/desired", 588 RhiCliAdminOperationV1::PresenceRender => "/v1/presence/render", 589 RhiCliAdminOperationV1::PresenceRefresh => "/v1/presence/refresh", 590 } 591 } 592 593 #[cfg(any(target_os = "linux", target_os = "macos"))] 594 #[test] 595 fn cli_admin_operations_match_the_complete_governed_route_inventory() { 596 assert_eq!( 597 [ 598 RhiCliAdminOperationV1::Status, 599 RhiCliAdminOperationV1::EffectiveConfig, 600 RhiCliAdminOperationV1::IdentityStatus, 601 RhiCliAdminOperationV1::IdentityPublic, 602 RhiCliAdminOperationV1::StateStatus, 603 RhiCliAdminOperationV1::StateBackup, 604 RhiCliAdminOperationV1::MetricsSnapshot, 605 RhiCliAdminOperationV1::ReconciliationStatus, 606 RhiCliAdminOperationV1::ReconciliationJobs, 607 RhiCliAdminOperationV1::ReconciliationRefresh, 608 RhiCliAdminOperationV1::Sources, 609 RhiCliAdminOperationV1::TradeProjection, 610 RhiCliAdminOperationV1::TradeReportCurrent, 611 RhiCliAdminOperationV1::TradeReports, 612 RhiCliAdminOperationV1::PublicationBacklog, 613 RhiCliAdminOperationV1::PublicationTargets, 614 RhiCliAdminOperationV1::PublicationRetry, 615 RhiCliAdminOperationV1::PresenceDesired, 616 RhiCliAdminOperationV1::PresenceRender, 617 RhiCliAdminOperationV1::PresenceRefresh, 618 ] 619 .map(RhiCliAdminOperationV1::route), 620 rhi::RhiAdminRoute::ALL 621 ); 622 } 623 624 #[test] 625 fn execution_plan_is_safe_and_the_binary_parses_and_plans_once() { 626 let invocation = parse_rhi_cli_v1_from([ 627 "rhi", 628 "--profile", 629 "repo-local", 630 "--instance", 631 "secret-instance", 632 "--repo-local-root", 633 "/secret/repository", 634 "--config", 635 "/secret/config.toml", 636 "publication", 637 "retry", 638 "--operation-id", 639 "operation-1", 640 "--workflow-id", 641 "0000000000000000000000000000000000000000000000000000000000000000", 642 "--expected-generation", 643 "1", 644 ]) 645 .expect("valid invocation"); 646 let rendered = format!("{invocation:?} {:?}", plan_rhi_cli_v1(&invocation)); 647 for forbidden in [ 648 "secret-instance", 649 "/secret/repository", 650 "/secret/config.toml", 651 ] { 652 assert!(!rendered.contains(forbidden)); 653 } 654 655 assert_eq!( 656 MAIN_SOURCE 657 .matches("parse_rhi_cli_v1_from(std::env::args_os())") 658 .count(), 659 1 660 ); 661 assert_eq!( 662 MAIN_SOURCE 663 .matches("execute_rhi_cli_v1_with_signal_source") 664 .count(), 665 1 666 ); 667 assert_eq!( 668 PROCESS_SOURCE 669 .matches("plan_rhi_cli_v1(&invocation)") 670 .count(), 671 1 672 ); 673 for source in [CLI_SOURCE, MAIN_SOURCE] { 674 for forbidden in ["sqlx::", "RhiStateHost", "open_rhi_state_"] { 675 assert!(!source.contains(forbidden), "found `{forbidden}`"); 676 } 677 } 678 } 679 680 #[test] 681 fn prototype_and_unsafe_commands_are_absent_and_errors_are_safe() { 682 for arguments in [ 683 vec![ 684 "rhi", 685 "--profile", 686 "service-host", 687 "--instance", 688 "default", 689 "--allow-generate-identity", 690 "run", 691 ], 692 vec![ 693 "rhi", 694 "--profile", 695 "service-host", 696 "--instance", 697 "default", 698 "--identity", 699 "/secret/identity", 700 "run", 701 ], 702 vec![ 703 "rhi", 704 "--profile", 705 "service-host", 706 "--instance", 707 "default", 708 "--state-path", 709 "/tmp/state", 710 "run", 711 ], 712 vec![ 713 "rhi", 714 "--profile", 715 "service-host", 716 "--instance", 717 "default", 718 "--worker", 719 "rhi", 720 "run", 721 ], 722 vec![ 723 "rhi", 724 "--profile", 725 "service-host", 726 "--instance", 727 "default", 728 "identity", 729 "rekey", 730 ], 731 vec![ 732 "rhi", 733 "--profile", 734 "service-host", 735 "--instance", 736 "default", 737 "identity", 738 "replace", 739 ], 740 vec![ 741 "rhi", 742 "--profile", 743 "service-host", 744 "--instance", 745 "default", 746 "attestation-smoke", 747 ], 748 ] { 749 let failure = parse_rhi_cli_v1_from(arguments).expect_err("removed surface"); 750 assert_eq!(failure.kind(), RhiCliV1ErrorKind::InvalidArguments); 751 assert!(Error::source(&failure).is_none()); 752 } 753 754 let secret = "never-render-cli-secret"; 755 let failure = 756 parse_rhi_cli_v1_from(["rhi", "--profile", secret, "--instance", "default", "run"]) 757 .expect_err("secret profile"); 758 assert!(!format!("{failure} {failure:?}").contains(secret)); 759 }