rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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 }