myc

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

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 }