myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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 }