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 }