lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

cli.rs (8819B)


      1 //! Sealed CLI-v1 argument plans for hardened service management.
      2 
      3 use core::fmt;
      4 use std::ffi::{OsStr, OsString};
      5 
      6 use radroots_runtime_paths::{RadrootsPathProfile, RuntimeContext};
      7 
      8 use crate::RadrootsRuntimeManagerError;
      9 
     10 const CLI_PATH_MAX_UTF8_BYTES: usize = 4_096;
     11 
     12 /// Common hardened-service CLI-v1 operations governed by this package.
     13 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     14 pub enum ManagedCliCommand {
     15     ConfigInit,
     16     ConfigValidate,
     17     StateInit,
     18     Run,
     19     Status,
     20     Doctor,
     21 }
     22 
     23 impl ManagedCliCommand {
     24     fn tokens(self) -> &'static [&'static str] {
     25         match self {
     26             Self::ConfigInit => &["config", "init"],
     27             Self::ConfigValidate => &["config", "validate"],
     28             Self::StateInit => &["state", "init"],
     29             Self::Run => &["run"],
     30             Self::Status => &["status"],
     31             Self::Doctor => &["doctor"],
     32         }
     33     }
     34 }
     35 
     36 /// Validated arguments for a caller-owned Myc or RHI executable.
     37 ///
     38 /// The plan intentionally contains no program, executable, archive, channel,
     39 /// or install path. Those distribution concerns remain outside Step219.
     40 /// External construction is sealed so every argument remains bound to the
     41 /// selected [`RuntimeContext`].
     42 ///
     43 /// ```compile_fail
     44 /// use radroots_runtime_manager::ManagedCliInvocation;
     45 ///
     46 /// let _ = ManagedCliInvocation {
     47 ///     command: todo!(),
     48 ///     profile: todo!(),
     49 ///     arguments: todo!(),
     50 /// };
     51 /// ```
     52 #[derive(Clone, PartialEq, Eq)]
     53 pub struct ManagedCliInvocation {
     54     command: ManagedCliCommand,
     55     profile: RadrootsPathProfile,
     56     arguments: Box<[OsString]>,
     57 }
     58 
     59 impl ManagedCliInvocation {
     60     pub(crate) fn for_context(
     61         context: &RuntimeContext,
     62         command: ManagedCliCommand,
     63     ) -> Result<Self, RadrootsRuntimeManagerError> {
     64         let profile = cli_profile(context.profile())?;
     65         let mut arguments = Vec::with_capacity(9);
     66         arguments.extend([
     67             OsString::from("--profile"),
     68             OsString::from(profile),
     69             OsString::from("--instance"),
     70             OsString::from(context.instance().as_str()),
     71         ]);
     72         if context.profile() == RadrootsPathProfile::RepoLocal {
     73             let root = context
     74                 .repo_local_root()
     75                 .ok_or(RadrootsRuntimeManagerError::ContextMismatch)?;
     76             if root
     77                 .to_str()
     78                 .is_none_or(|value| value.len() > CLI_PATH_MAX_UTF8_BYTES)
     79             {
     80                 return Err(RadrootsRuntimeManagerError::ContextMismatch);
     81             }
     82             arguments.push(OsString::from("--repo-local-root"));
     83             arguments.push(root.as_os_str().to_owned());
     84         } else if context.repo_local_root().is_some() {
     85             return Err(RadrootsRuntimeManagerError::ContextMismatch);
     86         }
     87         arguments.extend(command.tokens().iter().map(OsString::from));
     88         Ok(Self {
     89             command,
     90             profile: context.profile(),
     91             arguments: arguments.into_boxed_slice(),
     92         })
     93     }
     94 
     95     #[must_use]
     96     pub const fn command(&self) -> ManagedCliCommand {
     97         self.command
     98     }
     99 
    100     #[must_use]
    101     pub const fn profile(&self) -> RadrootsPathProfile {
    102         self.profile
    103     }
    104 
    105     #[must_use]
    106     pub fn arguments(&self) -> &[OsString] {
    107         &self.arguments
    108     }
    109 }
    110 
    111 impl fmt::Debug for ManagedCliInvocation {
    112     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    113         formatter
    114             .debug_struct("ManagedCliInvocation")
    115             .field("command", &self.command)
    116             .field("profile", &self.profile)
    117             .field("argument_count", &self.arguments.len())
    118             .field("arguments", &"[redacted]")
    119             .finish()
    120     }
    121 }
    122 
    123 fn cli_profile(
    124     profile: RadrootsPathProfile,
    125 ) -> Result<&'static OsStr, RadrootsRuntimeManagerError> {
    126     match profile {
    127         RadrootsPathProfile::InteractiveUser => Ok(OsStr::new("interactive")),
    128         RadrootsPathProfile::ServiceHost => Ok(OsStr::new("service-host")),
    129         RadrootsPathProfile::RepoLocal => Ok(OsStr::new("repo-local")),
    130         RadrootsPathProfile::MobileNative => Err(RadrootsRuntimeManagerError::UnsupportedProfile),
    131     }
    132 }
    133 
    134 #[cfg(test)]
    135 mod tests {
    136     use std::{ffi::OsString, path::PathBuf};
    137 
    138     use radroots_runtime_paths::{
    139         InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
    140         RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
    141     };
    142 
    143     use super::{ManagedCliCommand, ManagedCliInvocation, cli_profile};
    144 
    145     fn context(profile: RadrootsPathProfile) -> RuntimeContext {
    146         let root = (profile == RadrootsPathProfile::RepoLocal)
    147             .then(|| PathBuf::from("/sensitive/project-root"));
    148         RuntimeContext::resolve(
    149             &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
    150             RuntimeContextBootstrap::new(
    151                 profile,
    152                 root,
    153                 if profile == RadrootsPathProfile::RepoLocal {
    154                     RuntimeContextSource::BootstrapCli
    155                 } else {
    156                     RuntimeContextSource::SafeDefault
    157                 },
    158                 RuntimeContextSource::BootstrapCli,
    159             )
    160             .expect("bootstrap"),
    161             ServiceId::new("myc").expect("service"),
    162             InstanceId::new("primary").expect("instance"),
    163         )
    164         .expect("context")
    165     }
    166 
    167     #[test]
    168     fn every_common_command_uses_the_exact_cli_v1_shape() {
    169         for (command, suffix) in [
    170             (ManagedCliCommand::ConfigInit, &["config", "init"][..]),
    171             (
    172                 ManagedCliCommand::ConfigValidate,
    173                 &["config", "validate"][..],
    174             ),
    175             (ManagedCliCommand::StateInit, &["state", "init"][..]),
    176             (ManagedCliCommand::Run, &["run"][..]),
    177             (ManagedCliCommand::Status, &["status"][..]),
    178             (ManagedCliCommand::Doctor, &["doctor"][..]),
    179         ] {
    180             let invocation = ManagedCliInvocation::for_context(
    181                 &context(RadrootsPathProfile::ServiceHost),
    182                 command,
    183             )
    184             .expect("invocation");
    185             let mut expected = vec![
    186                 OsString::from("--profile"),
    187                 OsString::from("service-host"),
    188                 OsString::from("--instance"),
    189                 OsString::from("primary"),
    190             ];
    191             expected.extend(suffix.iter().map(OsString::from));
    192             assert_eq!(invocation.arguments(), expected);
    193             assert_eq!(invocation.command(), command);
    194         }
    195     }
    196 
    197     #[test]
    198     fn profile_names_match_both_service_cli_v1_parsers() {
    199         for (profile, expected) in [
    200             (RadrootsPathProfile::InteractiveUser, "interactive"),
    201             (RadrootsPathProfile::ServiceHost, "service-host"),
    202             (RadrootsPathProfile::RepoLocal, "repo-local"),
    203         ] {
    204             assert_eq!(cli_profile(profile).expect("profile"), expected);
    205         }
    206         assert!(cli_profile(RadrootsPathProfile::MobileNative).is_err());
    207     }
    208 
    209     #[test]
    210     fn repo_local_plan_preserves_the_validated_explicit_root_and_redacts_debug() {
    211         let invocation = ManagedCliInvocation::for_context(
    212             &context(RadrootsPathProfile::RepoLocal),
    213             ManagedCliCommand::Run,
    214         )
    215         .expect("invocation");
    216         assert_eq!(
    217             invocation.arguments(),
    218             [
    219                 "--profile",
    220                 "repo-local",
    221                 "--instance",
    222                 "primary",
    223                 "--repo-local-root",
    224                 "/sensitive/project-root",
    225                 "run",
    226             ]
    227             .map(OsString::from)
    228         );
    229         let debug = format!("{invocation:?}");
    230         assert!(!debug.contains("sensitive"));
    231         assert!(!debug.contains("project-root"));
    232     }
    233 
    234     #[test]
    235     fn cli_plan_rejects_a_context_root_outside_the_cli_v1_text_bound() {
    236         let root = format!("/{}", "x".repeat(super::CLI_PATH_MAX_UTF8_BYTES));
    237         let context = RuntimeContext::resolve(
    238             &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
    239             RuntimeContextBootstrap::new(
    240                 RadrootsPathProfile::RepoLocal,
    241                 Some(PathBuf::from(root)),
    242                 RuntimeContextSource::BootstrapCli,
    243                 RuntimeContextSource::BootstrapCli,
    244             )
    245             .expect("bootstrap"),
    246             ServiceId::new("myc").expect("service"),
    247             InstanceId::new("primary").expect("instance"),
    248         )
    249         .expect("context");
    250         assert_eq!(
    251             ManagedCliInvocation::for_context(&context, ManagedCliCommand::Run),
    252             Err(crate::RadrootsRuntimeManagerError::ContextMismatch)
    253         );
    254     }
    255 }