services_hardening_cli.rs (19704B)
1 #![forbid(unsafe_code)] 2 3 use std::collections::BTreeSet; 4 use std::error::Error; 5 use std::path::Path; 6 7 use myc::{ 8 INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliAdminOperationV1, MycCliOfflineOperationV1, 9 MycCliPrimaryAuthorityV1, MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, 10 MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from, plan_myc_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 base(command: &[&str]) -> Vec<String> { 20 let mut arguments = vec![ 21 "myc".to_owned(), 22 "--profile".to_owned(), 23 "service-host".to_owned(), 24 "--instance".to_owned(), 25 "primary".to_owned(), 26 ]; 27 arguments.extend(command.iter().map(|value| (*value).to_owned())); 28 arguments 29 } 30 31 #[test] 32 fn root_api_freezes_the_exact_command_inventory() { 33 let vectors = [ 34 (vec!["run"], "run"), 35 (vec!["config", "init"], "config_init"), 36 (vec!["config", "validate"], "config_validate"), 37 (vec!["config", "show"], "config_show"), 38 (vec!["config", "schema"], "config_schema"), 39 ( 40 vec!["config", "apply", "--candidate-config", "/candidate.toml"], 41 "config_apply", 42 ), 43 (vec!["state", "init"], "state_init"), 44 (vec!["state", "status"], "state_status"), 45 (backup_command(), "state_backup"), 46 (restore_command(), "state_restore"), 47 (vec!["state", "verify"], "state_verify"), 48 (vec!["state", "migrate"], "state_migrate"), 49 ( 50 vec!["identity", "init", "--role", "transport"], 51 "identity_init", 52 ), 53 ( 54 vec!["identity", "status", "--role", "user"], 55 "identity_status", 56 ), 57 ( 58 vec!["identity", "export-public", "--role", "discovery"], 59 "identity_export_public", 60 ), 61 (vec!["status"], "status"), 62 (vec!["doctor"], "doctor"), 63 ]; 64 65 for (arguments, expected) in vectors { 66 assert_eq!( 67 command_name( 68 parse_myc_cli_v1_from(base(&arguments)) 69 .expect("governed command") 70 .command() 71 ), 72 expected 73 ); 74 } 75 } 76 77 fn backup_command() -> Vec<&'static str> { 78 vec![ 79 "state", 80 "backup", 81 "--operation-id", 82 "backup-01", 83 "--target", 84 "/backup/new", 85 "--expected-generation", 86 "7", 87 "--confirm", 88 ] 89 } 90 91 fn restore_command() -> Vec<&'static str> { 92 vec![ 93 "state", 94 "restore", 95 "--manifest", 96 "/backup/manifest.json", 97 "--manifest-sha256", 98 "1111111111111111111111111111111111111111111111111111111111111111", 99 "--bundle", 100 "/backup/bundle", 101 "--maximum-state-bytes", 102 "1048576", 103 "--confirm", 104 ] 105 } 106 107 fn command_name(command: &MycCommandV1) -> &'static str { 108 match command { 109 MycCommandV1::Run => "run", 110 MycCommandV1::Config(MycConfigCommandV1::Init) => "config_init", 111 MycCommandV1::Config(MycConfigCommandV1::Validate) => "config_validate", 112 MycCommandV1::Config(MycConfigCommandV1::Show) => "config_show", 113 MycCommandV1::Config(MycConfigCommandV1::Schema) => "config_schema", 114 MycCommandV1::Config(MycConfigCommandV1::Apply(_)) => "config_apply", 115 MycCommandV1::State(MycStateCommandV1::Init) => "state_init", 116 MycCommandV1::State(MycStateCommandV1::Status) => "state_status", 117 MycCommandV1::State(MycStateCommandV1::Backup(_)) => "state_backup", 118 MycCommandV1::State(MycStateCommandV1::Restore(_)) => "state_restore", 119 MycCommandV1::State(MycStateCommandV1::Verify) => "state_verify", 120 MycCommandV1::State(MycStateCommandV1::Migrate) => "state_migrate", 121 MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => "identity_init", 122 MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => "identity_status", 123 MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => "identity_export_public", 124 MycCommandV1::Status => "status", 125 MycCommandV1::Doctor => "doctor", 126 } 127 } 128 129 #[test] 130 fn root_api_exposes_validated_cross_bound_bootstrap_values() { 131 let invocation = parse_myc_cli_v1_from([ 132 "myc", 133 "config", 134 "validate", 135 "--profile", 136 "repo-local", 137 "--instance", 138 "dev-01", 139 "--repo-local-root", 140 "/repo/radroots", 141 "--config", 142 "/repo/radroots/config/services/myc/config.toml", 143 ]) 144 .expect("repo-local invocation"); 145 assert_eq!(invocation.profile(), MycBootstrapProfileV1::RepoLocal); 146 assert_eq!(invocation.instance().as_str(), "dev-01"); 147 assert_eq!( 148 invocation.repo_local_root(), 149 Some(Path::new("/repo/radroots")) 150 ); 151 assert_eq!( 152 invocation.config_path(), 153 Some(Path::new("/repo/radroots/config/services/myc/config.toml")) 154 ); 155 assert_eq!(INSTANCE_ID_MAX_BYTES, 128); 156 } 157 158 #[test] 159 fn every_command_has_one_exact_nonforgeable_execution_plan() { 160 let vectors = [ 161 ("run", vec!["run"], "daemon", None, None, false), 162 ( 163 "config init", 164 vec!["config", "init"], 165 "offline", 166 Some("config"), 167 None, 168 false, 169 ), 170 ( 171 "config validate", 172 vec!["config", "validate"], 173 "offline", 174 Some("config"), 175 None, 176 false, 177 ), 178 ( 179 "config show", 180 vec!["config", "show"], 181 "offline", 182 Some("config"), 183 None, 184 false, 185 ), 186 ( 187 "config schema", 188 vec!["config", "schema"], 189 "offline", 190 Some("config"), 191 None, 192 false, 193 ), 194 ( 195 "config apply", 196 vec!["config", "apply", "--candidate-config", "/candidate.toml"], 197 "offline", 198 Some("config"), 199 None, 200 false, 201 ), 202 ( 203 "state init", 204 vec!["state", "init"], 205 "offline", 206 Some("state_exclusive"), 207 None, 208 false, 209 ), 210 ( 211 "state status", 212 vec!["state", "status"], 213 "live_unix_admin", 214 Some("state_read_only"), 215 Some("/v1/state/status"), 216 true, 217 ), 218 ( 219 "state backup", 220 backup_command(), 221 "live_unix_admin", 222 Some("state_read_only"), 223 Some("/v1/state/backup"), 224 true, 225 ), 226 ( 227 "state restore", 228 restore_command(), 229 "offline", 230 Some("state_exclusive"), 231 None, 232 false, 233 ), 234 ( 235 "state verify", 236 vec!["state", "verify"], 237 "offline", 238 Some("state_exclusive"), 239 None, 240 false, 241 ), 242 ( 243 "state migrate", 244 vec!["state", "migrate"], 245 "offline", 246 Some("state_exclusive"), 247 None, 248 false, 249 ), 250 ( 251 "identity init", 252 vec!["identity", "init", "--role", "transport"], 253 "offline", 254 Some("identity_exclusive"), 255 None, 256 false, 257 ), 258 ( 259 "identity status", 260 vec!["identity", "status", "--role", "user"], 261 "live_unix_admin", 262 Some("identity_read_only"), 263 Some("/v1/identity/status"), 264 true, 265 ), 266 ( 267 "identity export-public", 268 vec!["identity", "export-public", "--role", "discovery"], 269 "live_unix_admin", 270 Some("identity_read_only"), 271 Some("/v1/identity/public"), 272 true, 273 ), 274 ( 275 "status", 276 vec!["status"], 277 "live_unix_admin", 278 Some("state_read_only"), 279 Some("/v1/status"), 280 true, 281 ), 282 ( 283 "doctor", 284 vec!["doctor"], 285 "offline", 286 Some("doctor"), 287 None, 288 false, 289 ), 290 ]; 291 let contract: serde_json::Value = 292 serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract"); 293 let dispatch = contract 294 .get("cli_dispatch") 295 .and_then(serde_json::Value::as_object) 296 .expect("CLI dispatch contract"); 297 assert_eq!( 298 dispatch.keys().map(String::as_str).collect::<BTreeSet<_>>(), 299 BTreeSet::from([ 300 "bootstrap", 301 "commands", 302 "live_direct_sqlite_access", 303 "live_mutation_offline_fallback", 304 "parse_count", 305 "primary_authorities", 306 "read_only_offline_fallback_requires_free_daemon_writer_lock", 307 ]) 308 ); 309 assert_eq!( 310 dispatch 311 .get("parse_count") 312 .and_then(serde_json::Value::as_u64), 313 Some(1) 314 ); 315 assert_eq!( 316 dispatch 317 .get("live_direct_sqlite_access") 318 .and_then(serde_json::Value::as_bool), 319 Some(false) 320 ); 321 assert_eq!( 322 dispatch.get("primary_authorities"), 323 Some(&serde_json::json!(["daemon", "offline", "live_unix_admin"])) 324 ); 325 assert_eq!( 326 dispatch 327 .get("read_only_offline_fallback_requires_free_daemon_writer_lock") 328 .and_then(serde_json::Value::as_bool), 329 Some(true) 330 ); 331 assert_eq!( 332 dispatch 333 .get("live_mutation_offline_fallback") 334 .and_then(serde_json::Value::as_bool), 335 Some(false) 336 ); 337 assert_eq!( 338 dispatch.get("bootstrap"), 339 Some(&serde_json::json!({ 340 "output_modes": ["human", "json"], 341 "default_output_mode": "human", 342 "stdout": "results_only", 343 "stderr": "diagnostics_only", 344 "config_init": { 345 "source": "bounded_nonsecret_toml_stdin", 346 "persistence": "create_new_selected_path_mode_0600_file_and_parent_sync", 347 }, 348 "config_loader": { 349 "path": "selected_absolute_explicit_or_canonical_default", 350 "maximum_utf8_bytes": 1048576, 351 "no_follow": true, 352 "regular_file": true, 353 "single_link": true, 354 "effective_user_owner": true, 355 "group_or_other_write": false, 356 "revalidate_after_read": ["parent_device_inode", "file_device_inode", "file_length"], 357 }, 358 "config_apply": { 359 "candidate_argument": "--candidate-config_absolute_path", 360 "current_source": "selected_config_path", 361 "mutates_config_files": false, 362 }, 363 "identity": { 364 "role_argument": "--role_transport_user_discovery", 365 "init_provider": "configured_encrypted_file_only", 366 "init_secret_source": "stdin_fixed_binary_v1_117_bytes", 367 }, 368 "state_init": { 369 "source_generation": "system_entropy_nonzero_32_bytes", 370 "created_at": "system_wall_clock", 371 "existing_state_open": "sealed_intent_discovers_actual_metadata", 372 }, 373 "runtime": { 374 "owner": "myc_binary", 375 "count_per_process": 1, 376 "worker_threads_default": 4, 377 "worker_threads_range": [2, 32], 378 "blocking_threads_default": 8, 379 "blocking_threads_range": [1, 32], 380 "cpu_derived_defaults": false, 381 }, 382 "backup": { 383 "operation_id_argument": "--operation-id", 384 "target_argument": "--target-new-absolute-directory", 385 "expected_generation_argument": "--expected-generation", 386 "confirmation_argument": "--confirm", 387 "offline_stdout": "exact_canonical_manifest_bytes_no_trailing_newline", 388 }, 389 "restore": { 390 "manifest_argument": "--manifest-absolute-file", 391 "manifest_digest_argument": "--manifest-sha256", 392 "bundle_argument": "--bundle-absolute-directory", 393 "maximum_state_bytes_argument": "--maximum-state-bytes", 394 "confirmation_argument": "--confirm", 395 "expected_identity_source": "trusted_digest_bound_manifest_before_live_database_open", 396 }, 397 "run": { 398 "config_source": "secure_selected_path_loader", 399 "runtime_owner": "myc_binary", 400 "graph_owner": "myc-runtime-graph-shutdown", 401 }, 402 "unsupported_command_success": false, 403 })) 404 ); 405 let commands = dispatch 406 .get("commands") 407 .and_then(serde_json::Value::as_array) 408 .expect("command inventory"); 409 assert_eq!(commands.len(), vectors.len()); 410 411 for (index, (command, arguments, authority, offline, route, fallback)) in 412 vectors.into_iter().enumerate() 413 { 414 let invocation = parse_myc_cli_v1_from(base(&arguments)).expect("command"); 415 let plan = plan_myc_cli_v1(&invocation); 416 assert_eq!(authority_name(plan.primary_authority()), authority); 417 assert_eq!(plan.offline_operation().map(offline_name), offline); 418 assert_eq!(plan.admin_operation().map(admin_path), route); 419 assert_eq!(plan.allows_daemon_unavailable_offline_fallback(), fallback); 420 421 let row = commands[index].as_object().expect("command row"); 422 let mut expected_keys = BTreeSet::from(["command", "primary_authority"]); 423 if offline.is_some() { 424 expected_keys.insert("offline_operation"); 425 } 426 if route.is_some() { 427 expected_keys.insert("admin_route"); 428 } 429 if fallback { 430 expected_keys.insert("daemon_unavailable_offline_fallback"); 431 } 432 assert_eq!( 433 row.keys().map(String::as_str).collect::<BTreeSet<_>>(), 434 expected_keys 435 ); 436 assert_eq!( 437 row.get("command").and_then(serde_json::Value::as_str), 438 Some(command) 439 ); 440 assert_eq!( 441 row.get("primary_authority") 442 .and_then(serde_json::Value::as_str), 443 Some(authority) 444 ); 445 assert_eq!( 446 row.get("offline_operation") 447 .and_then(serde_json::Value::as_str), 448 offline 449 ); 450 assert_eq!( 451 row.get("admin_route").and_then(serde_json::Value::as_str), 452 route 453 ); 454 assert_eq!( 455 row.get("daemon_unavailable_offline_fallback") 456 .and_then(serde_json::Value::as_bool) 457 .unwrap_or(false), 458 fallback 459 ); 460 } 461 } 462 463 fn authority_name(authority: MycCliPrimaryAuthorityV1) -> &'static str { 464 match authority { 465 MycCliPrimaryAuthorityV1::Daemon => "daemon", 466 MycCliPrimaryAuthorityV1::Offline => "offline", 467 MycCliPrimaryAuthorityV1::LiveUnixAdmin => "live_unix_admin", 468 } 469 } 470 471 fn offline_name(operation: MycCliOfflineOperationV1) -> &'static str { 472 match operation { 473 MycCliOfflineOperationV1::Config => "config", 474 MycCliOfflineOperationV1::StateExclusive => "state_exclusive", 475 MycCliOfflineOperationV1::StateReadOnly => "state_read_only", 476 MycCliOfflineOperationV1::IdentityExclusive => "identity_exclusive", 477 MycCliOfflineOperationV1::IdentityReadOnly => "identity_read_only", 478 MycCliOfflineOperationV1::Doctor => "doctor", 479 } 480 } 481 482 fn admin_path(operation: MycCliAdminOperationV1) -> &'static str { 483 match operation { 484 MycCliAdminOperationV1::Status => "/v1/status", 485 MycCliAdminOperationV1::StateStatus => "/v1/state/status", 486 MycCliAdminOperationV1::StateBackup => "/v1/state/backup", 487 MycCliAdminOperationV1::IdentityStatus => "/v1/identity/status", 488 MycCliAdminOperationV1::IdentityPublic => "/v1/identity/public", 489 } 490 } 491 492 #[cfg(any(target_os = "linux", target_os = "macos"))] 493 #[test] 494 fn cli_admin_operations_match_the_governed_route_inventory() { 495 use myc::MycAdminRoute; 496 497 let vectors = [ 498 (MycCliAdminOperationV1::Status, MycAdminRoute::Status), 499 ( 500 MycCliAdminOperationV1::StateStatus, 501 MycAdminRoute::StateStatus, 502 ), 503 ( 504 MycCliAdminOperationV1::StateBackup, 505 MycAdminRoute::StateBackup, 506 ), 507 ( 508 MycCliAdminOperationV1::IdentityStatus, 509 MycAdminRoute::IdentityStatus, 510 ), 511 ( 512 MycCliAdminOperationV1::IdentityPublic, 513 MycAdminRoute::IdentityPublic, 514 ), 515 ]; 516 for (operation, route) in vectors { 517 assert_eq!(operation.route(), route); 518 assert_eq!(admin_path(operation), route.path()); 519 } 520 } 521 522 #[test] 523 fn execution_plan_debug_retains_no_bootstrap_or_path_values() { 524 let invocation = parse_myc_cli_v1_from([ 525 "myc", 526 "--profile", 527 "repo-local", 528 "--instance", 529 "secret-instance", 530 "--repo-local-root", 531 "/secret/repository", 532 "--config", 533 "/secret/config.toml", 534 "identity", 535 "export-public", 536 "--role", 537 "discovery", 538 ]) 539 .expect("valid invocation"); 540 let rendered = format!("{invocation:?} {:?}", plan_myc_cli_v1(&invocation)); 541 for forbidden in [ 542 "secret-instance", 543 "/secret/repository", 544 "/secret/config.toml", 545 ] { 546 assert!(!rendered.contains(forbidden)); 547 } 548 } 549 550 #[test] 551 fn root_api_rejects_prototype_and_arbitrary_leaf_arguments_safely() { 552 for arguments in [ 553 base(&["--env-file", "/secret/config.env", "run"]), 554 base(&["metrics"]), 555 base(&["persistence", "backup"]), 556 base(&["identity", "generate"]), 557 base(&["identity", "rekey"]), 558 base(&["identity", "replace"]), 559 base(&["run", "--relay-url", "wss://secret.example"]), 560 ] { 561 let error = parse_myc_cli_v1_from(arguments).expect_err("forbidden CLI shape"); 562 assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidArguments); 563 assert!(Error::source(&error).is_none()); 564 let rendered = format!("{error} {error:?}"); 565 assert!(!rendered.contains("secret")); 566 } 567 } 568 569 #[test] 570 fn parser_is_single_pass_pure_and_privately_implemented() { 571 assert_eq!(CLI_SOURCE.matches("RawMycCliV1::try_parse_from").count(), 1); 572 for forbidden in [ 573 "std::env::", 574 "env::args", 575 "std::fs::", 576 "tokio::", 577 "serde_json::", 578 "toml::", 579 "pub mod cli_v1", 580 "sqlx::", 581 "open_myc_state_", 582 "MycStateHost", 583 ] { 584 assert!(!CLI_SOURCE.contains(forbidden), "found `{forbidden}`"); 585 } 586 587 assert_eq!(MAIN_SOURCE.matches("parse_myc_cli_v1_from").count(), 1); 588 assert_eq!(MAIN_SOURCE.matches("execute_myc_cli_v1").count(), 1); 589 assert_eq!( 590 PROCESS_SOURCE 591 .matches("plan_myc_cli_v1(&invocation)") 592 .count(), 593 1 594 ); 595 for forbidden in ["sqlx::", "open_myc_state_", "MycStateHost"] { 596 assert!(!MAIN_SOURCE.contains(forbidden), "found `{forbidden}`"); 597 } 598 599 let root = include_str!("../src/lib.rs"); 600 assert!(root.contains("mod cli_v1;")); 601 assert!(!root.contains("pub mod cli_v1;")); 602 }