roots.rs (14908B)
1 use std::path::{Component, Path, PathBuf}; 2 3 use crate::{ 4 RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform, RadrootsRuntimePathsError, 5 }; 6 7 const APPLICATION_DIRECTORY: &str = "radroots"; 8 const MACOS_APPLICATION_DIRECTORY: &str = "Radroots"; 9 10 #[derive(Clone, Copy)] 11 enum XdgDirectory { 12 Config, 13 Data, 14 State, 15 Cache, 16 Runtime, 17 } 18 19 impl XdgDirectory { 20 const fn home_default(self) -> Option<&'static str> { 21 match self { 22 Self::Config => Some(".config"), 23 Self::Data => Some(".local/share"), 24 Self::State => Some(".local/state"), 25 Self::Cache => Some(".cache"), 26 Self::Runtime => None, 27 } 28 } 29 } 30 31 #[derive(Debug, Clone, PartialEq, Eq)] 32 pub(crate) struct RadrootsPaths { 33 pub(crate) config: PathBuf, 34 pub(crate) data: PathBuf, 35 pub(crate) cache: PathBuf, 36 pub(crate) logs: PathBuf, 37 pub(crate) run: PathBuf, 38 pub(crate) secrets: PathBuf, 39 } 40 41 impl RadrootsPaths { 42 #[must_use] 43 pub(crate) fn from_base_root(base_root: impl AsRef<Path>) -> Self { 44 let base_root = base_root.as_ref(); 45 Self { 46 config: base_root.join("config"), 47 data: base_root.join("data"), 48 cache: base_root.join("cache"), 49 logs: base_root.join("logs"), 50 run: base_root.join("run"), 51 secrets: base_root.join("secrets"), 52 } 53 } 54 } 55 56 #[derive(Debug, Clone, PartialEq, Eq)] 57 pub struct RadrootsPathResolver { 58 platform: RadrootsPlatform, 59 host_environment: RadrootsHostEnvironment, 60 } 61 62 impl RadrootsPathResolver { 63 #[must_use] 64 pub fn new(platform: RadrootsPlatform, host_environment: RadrootsHostEnvironment) -> Self { 65 Self { 66 platform, 67 host_environment, 68 } 69 } 70 71 #[must_use] 72 pub fn platform(&self) -> RadrootsPlatform { 73 self.platform 74 } 75 76 pub(crate) fn resolve( 77 &self, 78 profile: RadrootsPathProfile, 79 repo_local_root: Option<&Path>, 80 ) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { 81 match profile { 82 RadrootsPathProfile::InteractiveUser => self.resolve_interactive_user(), 83 RadrootsPathProfile::ServiceHost => self.resolve_service_host(), 84 RadrootsPathProfile::RepoLocal => { 85 let root = 86 repo_local_root.ok_or(RadrootsRuntimePathsError::MissingRepoLocalRoot)?; 87 validate_repo_local_root(root)?; 88 Ok(RadrootsPaths::from_base_root(root)) 89 } 90 RadrootsPathProfile::MobileNative => { 91 Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { 92 profile, 93 platform: self.platform, 94 }) 95 } 96 } 97 } 98 99 fn resolve_interactive_user(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { 100 match self.platform { 101 RadrootsPlatform::Linux => self.resolve_linux_interactive_user(), 102 RadrootsPlatform::Macos => self.resolve_macos_interactive_user(), 103 RadrootsPlatform::Windows => { 104 let appdata = self 105 .host_environment 106 .appdata_dir 107 .as_ref() 108 .ok_or(RadrootsRuntimePathsError::MissingWindowsUserDirs)?; 109 let localappdata = self 110 .host_environment 111 .localappdata_dir 112 .as_ref() 113 .ok_or(RadrootsRuntimePathsError::MissingWindowsUserDirs)?; 114 let config_root = appdata.join("Radroots"); 115 let local_root = localappdata.join("Radroots"); 116 Ok(RadrootsPaths { 117 config: config_root.join("config"), 118 data: local_root.join("data"), 119 cache: local_root.join("cache"), 120 logs: local_root.join("logs"), 121 run: local_root.join("run"), 122 secrets: config_root.join("secrets"), 123 }) 124 } 125 RadrootsPlatform::Android | RadrootsPlatform::Ios | RadrootsPlatform::Other => { 126 Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { 127 profile: RadrootsPathProfile::InteractiveUser, 128 platform: self.platform, 129 }) 130 } 131 } 132 } 133 134 fn resolve_linux_interactive_user(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { 135 let config = self.resolve_xdg_directory(XdgDirectory::Config)?; 136 let data = self.resolve_xdg_directory(XdgDirectory::Data)?; 137 let state = self.resolve_xdg_directory(XdgDirectory::State)?; 138 let cache = self.resolve_xdg_directory(XdgDirectory::Cache)?; 139 let run = self.resolve_xdg_directory(XdgDirectory::Runtime)?; 140 Ok(RadrootsPaths { 141 config: config.join(APPLICATION_DIRECTORY), 142 data: data.join(APPLICATION_DIRECTORY), 143 cache: cache.join(APPLICATION_DIRECTORY), 144 logs: state.join(APPLICATION_DIRECTORY).join("logs"), 145 run: run.join(APPLICATION_DIRECTORY), 146 secrets: config.join(APPLICATION_DIRECTORY).join("secrets"), 147 }) 148 } 149 150 fn resolve_xdg_directory( 151 &self, 152 directory: XdgDirectory, 153 ) -> Result<PathBuf, RadrootsRuntimePathsError> { 154 let configured = match directory { 155 XdgDirectory::Config => &self.host_environment.xdg_config_home, 156 XdgDirectory::Data => &self.host_environment.xdg_data_home, 157 XdgDirectory::State => &self.host_environment.xdg_state_home, 158 XdgDirectory::Cache => &self.host_environment.xdg_cache_home, 159 XdgDirectory::Runtime => &self.host_environment.xdg_runtime_dir, 160 }; 161 if let Some(path) = configured.as_ref().filter(|path| path.is_absolute()) { 162 return Ok(path.clone()); 163 } 164 let Some(default) = directory.home_default() else { 165 return Err(RadrootsRuntimePathsError::MissingXdgRuntimeDir); 166 }; 167 Ok(self.valid_home_dir(RadrootsPlatform::Linux)?.join(default)) 168 } 169 170 fn resolve_macos_interactive_user(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { 171 let home = self.valid_home_dir(RadrootsPlatform::Macos)?; 172 let application_support = home 173 .join("Library/Application Support") 174 .join(MACOS_APPLICATION_DIRECTORY); 175 Ok(RadrootsPaths { 176 config: application_support.join("config"), 177 data: application_support.join("data"), 178 cache: home 179 .join("Library/Caches") 180 .join(MACOS_APPLICATION_DIRECTORY), 181 logs: home.join("Library/Logs").join(MACOS_APPLICATION_DIRECTORY), 182 run: application_support.join("run"), 183 secrets: application_support.join("secrets"), 184 }) 185 } 186 187 fn valid_home_dir( 188 &self, 189 platform: RadrootsPlatform, 190 ) -> Result<&Path, RadrootsRuntimePathsError> { 191 let home = self 192 .host_environment 193 .home_dir 194 .as_deref() 195 .ok_or(RadrootsRuntimePathsError::MissingHomeDir { platform })?; 196 if home.is_absolute() { 197 Ok(home) 198 } else { 199 Err(RadrootsRuntimePathsError::InvalidHomeDir { platform }) 200 } 201 } 202 203 fn resolve_service_host(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { 204 match self.platform { 205 RadrootsPlatform::Linux => Ok(RadrootsPaths { 206 config: PathBuf::from("/etc/radroots"), 207 data: PathBuf::from("/var/lib/radroots"), 208 cache: PathBuf::from("/var/cache/radroots"), 209 logs: PathBuf::from("/var/log/radroots"), 210 run: PathBuf::from("/run/radroots"), 211 secrets: PathBuf::from("/etc/radroots/secrets"), 212 }), 213 RadrootsPlatform::Macos 214 | RadrootsPlatform::Windows 215 | RadrootsPlatform::Android 216 | RadrootsPlatform::Ios 217 | RadrootsPlatform::Other => { 218 Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { 219 profile: RadrootsPathProfile::ServiceHost, 220 platform: self.platform, 221 }) 222 } 223 } 224 } 225 } 226 227 pub(crate) fn validate_repo_local_root(root: &Path) -> Result<(), RadrootsRuntimePathsError> { 228 if !root.is_absolute() 229 || root.parent().is_none() 230 || root 231 .components() 232 .any(|component| matches!(component, Component::ParentDir)) 233 { 234 return Err(RadrootsRuntimePathsError::InvalidRepoLocalRoot); 235 } 236 Ok(()) 237 } 238 239 #[cfg(test)] 240 mod tests { 241 use std::path::{Path, PathBuf}; 242 243 use super::{RadrootsPathResolver, RadrootsPaths}; 244 use crate::{ 245 RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform, RadrootsRuntimePathsError, 246 }; 247 248 #[test] 249 fn repo_local_is_explicit_validated_and_one_base() { 250 let resolver = 251 RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); 252 assert_eq!( 253 resolver 254 .resolve(RadrootsPathProfile::RepoLocal, None) 255 .expect_err("missing root"), 256 RadrootsRuntimePathsError::MissingRepoLocalRoot 257 ); 258 for invalid in ["", ".", "relative/root", "/", "/repo/../escape"] { 259 assert_eq!( 260 resolver 261 .resolve(RadrootsPathProfile::RepoLocal, Some(Path::new(invalid))) 262 .expect_err("invalid root"), 263 RadrootsRuntimePathsError::InvalidRepoLocalRoot 264 ); 265 } 266 assert_eq!( 267 resolver 268 .resolve( 269 RadrootsPathProfile::RepoLocal, 270 Some(Path::new("/repo/.local/radroots")), 271 ) 272 .expect("repo roots"), 273 RadrootsPaths::from_base_root("/repo/.local/radroots") 274 ); 275 } 276 277 #[test] 278 fn linux_interactive_uses_injected_xdg_and_home_defaults() { 279 let resolver = RadrootsPathResolver::new( 280 RadrootsPlatform::Linux, 281 RadrootsHostEnvironment { 282 home_dir: Some(PathBuf::from("/home/treesap")), 283 xdg_config_home: Some(PathBuf::from("relative-ignored")), 284 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), 285 ..RadrootsHostEnvironment::default() 286 }, 287 ); 288 assert_eq!( 289 resolver 290 .resolve(RadrootsPathProfile::InteractiveUser, None) 291 .expect("linux interactive"), 292 RadrootsPaths { 293 config: PathBuf::from("/home/treesap/.config/radroots"), 294 data: PathBuf::from("/home/treesap/.local/share/radroots"), 295 cache: PathBuf::from("/home/treesap/.cache/radroots"), 296 logs: PathBuf::from("/home/treesap/.local/state/radroots/logs"), 297 run: PathBuf::from("/run/user/1000/radroots"), 298 secrets: PathBuf::from("/home/treesap/.config/radroots/secrets"), 299 } 300 ); 301 } 302 303 #[test] 304 fn linux_interactive_never_invents_runtime_root() { 305 let resolver = RadrootsPathResolver::new( 306 RadrootsPlatform::Linux, 307 RadrootsHostEnvironment { 308 home_dir: Some(PathBuf::from("/home/treesap")), 309 ..RadrootsHostEnvironment::default() 310 }, 311 ); 312 assert_eq!( 313 resolver 314 .resolve(RadrootsPathProfile::InteractiveUser, None) 315 .expect_err("runtime root is required"), 316 RadrootsRuntimePathsError::MissingXdgRuntimeDir 317 ); 318 } 319 320 #[test] 321 fn home_derived_roots_require_absolute_home() { 322 for platform in [RadrootsPlatform::Linux, RadrootsPlatform::Macos] { 323 let resolver = RadrootsPathResolver::new( 324 platform, 325 RadrootsHostEnvironment { 326 home_dir: Some(PathBuf::from("relative")), 327 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), 328 ..RadrootsHostEnvironment::default() 329 }, 330 ); 331 assert_eq!( 332 resolver 333 .resolve(RadrootsPathProfile::InteractiveUser, None) 334 .expect_err("relative home"), 335 RadrootsRuntimePathsError::InvalidHomeDir { platform } 336 ); 337 } 338 } 339 340 #[test] 341 fn service_host_linux_uses_canonical_roots() { 342 let resolver = 343 RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); 344 assert_eq!(resolver.platform(), RadrootsPlatform::Linux); 345 assert_eq!( 346 resolver 347 .resolve(RadrootsPathProfile::ServiceHost, None) 348 .expect("service host"), 349 RadrootsPaths { 350 config: PathBuf::from("/etc/radroots"), 351 data: PathBuf::from("/var/lib/radroots"), 352 cache: PathBuf::from("/var/cache/radroots"), 353 logs: PathBuf::from("/var/log/radroots"), 354 run: PathBuf::from("/run/radroots"), 355 secrets: PathBuf::from("/etc/radroots/secrets"), 356 } 357 ); 358 } 359 360 #[test] 361 fn unsupported_profile_platform_pairs_fail_closed() { 362 for platform in [ 363 RadrootsPlatform::Macos, 364 RadrootsPlatform::Windows, 365 RadrootsPlatform::Android, 366 RadrootsPlatform::Ios, 367 RadrootsPlatform::Other, 368 ] { 369 let resolver = RadrootsPathResolver::new(platform, RadrootsHostEnvironment::default()); 370 assert_eq!( 371 resolver 372 .resolve(RadrootsPathProfile::ServiceHost, None) 373 .expect_err("unsupported service host"), 374 RadrootsRuntimePathsError::UnsupportedProfilePlatform { 375 profile: RadrootsPathProfile::ServiceHost, 376 platform, 377 } 378 ); 379 } 380 for platform in [ 381 RadrootsPlatform::Linux, 382 RadrootsPlatform::Macos, 383 RadrootsPlatform::Windows, 384 RadrootsPlatform::Android, 385 RadrootsPlatform::Ios, 386 RadrootsPlatform::Other, 387 ] { 388 let resolver = RadrootsPathResolver::new(platform, RadrootsHostEnvironment::default()); 389 assert_eq!( 390 resolver 391 .resolve(RadrootsPathProfile::MobileNative, None) 392 .expect_err("mobile roots require a product-owned adapter"), 393 RadrootsRuntimePathsError::UnsupportedProfilePlatform { 394 profile: RadrootsPathProfile::MobileNative, 395 platform, 396 } 397 ); 398 } 399 } 400 }