paths.rs (15042B)
1 use std::path::{Path, PathBuf}; 2 3 use anyhow::{Result, bail}; 4 use serde::Serialize; 5 6 const RADROOTSD_RUNTIME_ID: &str = "radrootsd"; 7 const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml"; 8 const DEFAULT_SERVICE_IDENTITY_FILE_NAME: &str = "identity.secret.json"; 9 const TRANSPORT_PUBLISH_DATABASE_FILE_NAME: &str = "transport_publish.sqlite"; 10 const RADROOTSD_PATHS_PROFILE_ENV: &str = "RADROOTSD_PATHS_PROFILE"; 11 const RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RADROOTSD_PATHS_REPO_LOCAL_ROOT"; 12 const RADROOTSD_DEFAULT_SHARED_SECRET_BACKEND: &str = "encrypted_file"; 13 const RADROOTSD_ALLOWED_PROFILES: [&str; 3] = ["interactive_user", "service_host", "repo_local"]; 14 const RADROOTSD_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"]; 15 const SUBORDINATE_PATH_OVERRIDE_SOURCE: &str = "config_artifact"; 16 const SUBORDINATE_PATH_OVERRIDE_KEYS: [&str; 2] = [ 17 "config.service.logs_dir", 18 "config.transport_publish.database_path", 19 ]; 20 21 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 22 #[allow(dead_code)] 23 pub(crate) enum Platform { 24 Linux, 25 Macos, 26 Windows, 27 } 28 29 #[derive(Debug, Clone, Default, PartialEq, Eq)] 30 pub(crate) struct HostEnvironment { 31 pub(crate) home_dir: Option<PathBuf>, 32 pub(crate) appdata_dir: Option<PathBuf>, 33 pub(crate) localappdata_dir: Option<PathBuf>, 34 pub(crate) programdata_dir: Option<PathBuf>, 35 } 36 37 impl HostEnvironment { 38 fn current() -> Self { 39 Self { 40 home_dir: std::env::var_os("HOME").map(PathBuf::from), 41 appdata_dir: std::env::var_os("APPDATA").map(PathBuf::from), 42 localappdata_dir: std::env::var_os("LOCALAPPDATA").map(PathBuf::from), 43 programdata_dir: std::env::var_os("PROGRAMDATA").map(PathBuf::from), 44 } 45 } 46 } 47 48 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 49 pub(crate) enum PathProfile { 50 InteractiveUser, 51 ServiceHost, 52 RepoLocal, 53 } 54 55 impl PathProfile { 56 fn parse(value: &str) -> Result<Self> { 57 match value { 58 "interactive_user" => Ok(Self::InteractiveUser), 59 "service_host" => Ok(Self::ServiceHost), 60 "repo_local" => Ok(Self::RepoLocal), 61 _ => bail!("unknown radrootsd path profile `{value}`"), 62 } 63 } 64 65 const fn as_str(self) -> &'static str { 66 match self { 67 Self::InteractiveUser => "interactive_user", 68 Self::ServiceHost => "service_host", 69 Self::RepoLocal => "repo_local", 70 } 71 } 72 } 73 74 #[derive(Debug, Clone)] 75 pub(crate) struct PathResolver { 76 platform: Platform, 77 environment: HostEnvironment, 78 } 79 80 impl PathResolver { 81 pub(crate) const fn new(platform: Platform, environment: HostEnvironment) -> Self { 82 Self { 83 platform, 84 environment, 85 } 86 } 87 88 pub(crate) fn current() -> Self { 89 #[cfg(target_os = "windows")] 90 let platform = Platform::Windows; 91 #[cfg(target_os = "macos")] 92 let platform = Platform::Macos; 93 #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] 94 let platform = Platform::Linux; 95 Self::new(platform, HostEnvironment::current()) 96 } 97 98 fn roots(&self, profile: PathProfile, repo_local_root: Option<&Path>) -> Result<RuntimeRoots> { 99 match profile { 100 PathProfile::RepoLocal => repo_local_root 101 .map(RuntimeRoots::from_base) 102 .ok_or_else(|| anyhow::anyhow!("repo_local requires an explicit root")), 103 PathProfile::ServiceHost => match self.platform { 104 Platform::Linux | Platform::Macos => Ok(RuntimeRoots { 105 config: PathBuf::from("/etc/radroots"), 106 data: PathBuf::from("/var/lib/radroots"), 107 logs: PathBuf::from("/var/log/radroots"), 108 secrets: PathBuf::from("/etc/radroots/secrets"), 109 }), 110 Platform::Windows => { 111 let base = self 112 .environment 113 .programdata_dir 114 .as_deref() 115 .ok_or_else(|| anyhow::anyhow!("PROGRAMDATA is required"))? 116 .join("Radroots"); 117 Ok(RuntimeRoots { 118 config: base.join("config"), 119 data: base.join("data"), 120 logs: base.join("logs"), 121 secrets: base.join("secrets"), 122 }) 123 } 124 }, 125 PathProfile::InteractiveUser => match self.platform { 126 Platform::Linux | Platform::Macos => { 127 let base = self 128 .environment 129 .home_dir 130 .as_deref() 131 .ok_or_else(|| anyhow::anyhow!("HOME is required"))? 132 .join(".radroots"); 133 Ok(RuntimeRoots::from_base(base.as_path())) 134 } 135 Platform::Windows => { 136 let roaming = self 137 .environment 138 .appdata_dir 139 .as_deref() 140 .ok_or_else(|| anyhow::anyhow!("APPDATA is required"))? 141 .join("Radroots"); 142 let local = self 143 .environment 144 .localappdata_dir 145 .as_deref() 146 .ok_or_else(|| anyhow::anyhow!("LOCALAPPDATA is required"))? 147 .join("Radroots"); 148 Ok(RuntimeRoots { 149 config: roaming.join("config"), 150 data: local.join("data"), 151 logs: local.join("logs"), 152 secrets: roaming.join("secrets"), 153 }) 154 } 155 }, 156 } 157 } 158 } 159 160 #[derive(Debug, Clone)] 161 struct RuntimeRoots { 162 config: PathBuf, 163 data: PathBuf, 164 logs: PathBuf, 165 secrets: PathBuf, 166 } 167 168 impl RuntimeRoots { 169 fn from_base(base: &Path) -> Self { 170 Self { 171 config: base.join("config"), 172 data: base.join("data"), 173 logs: base.join("logs"), 174 secrets: base.join("secrets"), 175 } 176 } 177 178 fn service(self) -> Self { 179 Self { 180 config: self.config.join("services").join(RADROOTSD_RUNTIME_ID), 181 data: self.data.join("services").join(RADROOTSD_RUNTIME_ID), 182 logs: self.logs.join("services").join(RADROOTSD_RUNTIME_ID), 183 secrets: self.secrets.join("services").join(RADROOTSD_RUNTIME_ID), 184 } 185 } 186 } 187 188 #[derive(Debug, Clone, PartialEq, Eq)] 189 pub(crate) struct RuntimePathSelection { 190 pub(crate) profile: PathProfile, 191 pub(crate) repo_local_root: Option<PathBuf>, 192 profile_source: String, 193 repo_local_root_source: Option<String>, 194 } 195 196 impl RuntimePathSelection { 197 #[cfg(test)] 198 pub(crate) fn caller(profile: PathProfile, repo_local_root: Option<PathBuf>) -> Self { 199 Self { 200 profile, 201 repo_local_root, 202 profile_source: "caller".to_owned(), 203 repo_local_root_source: None, 204 } 205 } 206 207 fn from_env() -> Result<Self> { 208 let profile_value = std::env::var(RADROOTSD_PATHS_PROFILE_ENV).ok(); 209 let profile = profile_value 210 .as_deref() 211 .map(PathProfile::parse) 212 .transpose()? 213 .unwrap_or(PathProfile::InteractiveUser); 214 let repo_local_root = std::env::var_os(RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV) 215 .filter(|value| !value.is_empty()) 216 .map(PathBuf::from); 217 if profile == PathProfile::RepoLocal && repo_local_root.is_none() { 218 bail!("{RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV} is required for repo_local"); 219 } 220 Ok(Self { 221 profile, 222 repo_local_root, 223 profile_source: if profile_value.is_some() { 224 RADROOTSD_PATHS_PROFILE_ENV.to_owned() 225 } else { 226 "default".to_owned() 227 }, 228 repo_local_root_source: std::env::var_os(RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV) 229 .map(|_| RADROOTSD_PATHS_REPO_LOCAL_ROOT_ENV.to_owned()), 230 }) 231 } 232 } 233 234 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 235 pub struct RadrootsdRuntimePathOverrideContractOutput { 236 pub profile_source: String, 237 pub root_source: String, 238 pub repo_local_root: Option<PathBuf>, 239 pub repo_local_root_source: Option<String>, 240 pub subordinate_path_override_source: String, 241 pub subordinate_path_override_keys: Vec<String>, 242 } 243 244 #[derive(Debug, Clone, PartialEq, Eq)] 245 pub(crate) struct RadrootsdRuntimePaths { 246 pub(crate) config_path: PathBuf, 247 pub(crate) logs_dir: PathBuf, 248 pub(crate) identity_path: PathBuf, 249 pub(crate) transport_publish_database_path: PathBuf, 250 } 251 252 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 253 pub struct RadrootsdRuntimeContractOutput { 254 pub active_profile: String, 255 pub allowed_profiles: Vec<String>, 256 pub path_overrides: RadrootsdRuntimePathOverrideContractOutput, 257 pub default_shared_secret_backend: String, 258 pub allowed_shared_secret_backends: Vec<String>, 259 pub canonical_config_path: PathBuf, 260 pub canonical_logs_dir: PathBuf, 261 pub canonical_identity_path: PathBuf, 262 pub canonical_transport_publish_database_path: PathBuf, 263 } 264 265 pub(crate) fn process_path_selection() -> Result<(PathProfile, Option<PathBuf>)> { 266 let selection = RuntimePathSelection::from_env()?; 267 Ok((selection.profile, selection.repo_local_root)) 268 } 269 270 pub(crate) fn resolve_runtime_paths_with_resolver( 271 resolver: &PathResolver, 272 profile: PathProfile, 273 repo_local_root: Option<&Path>, 274 ) -> Result<RadrootsdRuntimePaths> { 275 let roots = resolver.roots(profile, repo_local_root)?.service(); 276 Ok(RadrootsdRuntimePaths { 277 config_path: roots.config.join(DEFAULT_CONFIG_FILE_NAME), 278 logs_dir: roots.logs, 279 identity_path: roots.secrets.join(DEFAULT_SERVICE_IDENTITY_FILE_NAME), 280 transport_publish_database_path: roots.data.join(TRANSPORT_PUBLISH_DATABASE_FILE_NAME), 281 }) 282 } 283 284 pub(crate) fn default_runtime_paths_for_process() -> Result<RadrootsdRuntimePaths> { 285 let (profile, repo_local_root) = process_path_selection()?; 286 resolve_runtime_paths_with_resolver( 287 &PathResolver::current(), 288 profile, 289 repo_local_root.as_deref(), 290 ) 291 } 292 293 pub(crate) fn default_transport_publish_database_path() -> PathBuf { 294 default_runtime_paths_for_process() 295 .expect("resolve canonical radrootsd runtime paths") 296 .transport_publish_database_path 297 } 298 299 pub fn default_config_path_for_process() -> Result<PathBuf> { 300 Ok(default_runtime_paths_for_process()?.config_path) 301 } 302 303 pub fn default_identity_path_for_process() -> Result<PathBuf> { 304 Ok(default_runtime_paths_for_process()?.identity_path) 305 } 306 307 pub fn runtime_contract_for_process() -> Result<RadrootsdRuntimeContractOutput> { 308 let selection = RuntimePathSelection::from_env()?; 309 runtime_contract_with_selection(&PathResolver::current(), &selection) 310 } 311 312 pub(crate) fn runtime_contract_with_selection( 313 resolver: &PathResolver, 314 selection: &RuntimePathSelection, 315 ) -> Result<RadrootsdRuntimeContractOutput> { 316 let paths = resolve_runtime_paths_with_resolver( 317 resolver, 318 selection.profile, 319 selection.repo_local_root.as_deref(), 320 )?; 321 Ok(RadrootsdRuntimeContractOutput { 322 active_profile: selection.profile.as_str().to_owned(), 323 allowed_profiles: RADROOTSD_ALLOWED_PROFILES 324 .into_iter() 325 .map(str::to_owned) 326 .collect(), 327 path_overrides: RadrootsdRuntimePathOverrideContractOutput { 328 profile_source: selection.profile_source.clone(), 329 root_source: if selection.profile == PathProfile::RepoLocal { 330 "explicit_repo_local_root".to_owned() 331 } else { 332 "host_defaults".to_owned() 333 }, 334 repo_local_root: selection.repo_local_root.clone(), 335 repo_local_root_source: selection.repo_local_root_source.clone(), 336 subordinate_path_override_source: SUBORDINATE_PATH_OVERRIDE_SOURCE.to_owned(), 337 subordinate_path_override_keys: SUBORDINATE_PATH_OVERRIDE_KEYS 338 .into_iter() 339 .map(str::to_owned) 340 .collect(), 341 }, 342 default_shared_secret_backend: RADROOTSD_DEFAULT_SHARED_SECRET_BACKEND.to_owned(), 343 allowed_shared_secret_backends: RADROOTSD_ALLOWED_SHARED_SECRET_BACKENDS 344 .into_iter() 345 .map(str::to_owned) 346 .collect(), 347 canonical_config_path: paths.config_path, 348 canonical_logs_dir: paths.logs_dir, 349 canonical_identity_path: paths.identity_path, 350 canonical_transport_publish_database_path: paths.transport_publish_database_path, 351 }) 352 } 353 354 #[cfg(test)] 355 mod tests { 356 use std::path::PathBuf; 357 358 use super::{ 359 HostEnvironment, PathProfile, PathResolver, Platform, RadrootsdRuntimeContractOutput, 360 RuntimePathSelection, default_config_path_for_process, runtime_contract_for_process, 361 runtime_contract_with_selection, 362 }; 363 364 fn linux_resolver() -> PathResolver { 365 PathResolver::new( 366 Platform::Linux, 367 HostEnvironment { 368 home_dir: Some(PathBuf::from("/home/treesap")), 369 ..HostEnvironment::default() 370 }, 371 ) 372 } 373 374 #[test] 375 fn process_path_entrypoints_remain_linked_in_test_builds() { 376 let _default_config_path: fn() -> anyhow::Result<PathBuf> = default_config_path_for_process; 377 let _runtime_contract: fn() -> anyhow::Result<RadrootsdRuntimeContractOutput> = 378 runtime_contract_for_process; 379 } 380 381 #[test] 382 fn runtime_contract_output_contains_canonical_runtime_paths() { 383 let contract = runtime_contract_with_selection( 384 &linux_resolver(), 385 &RuntimePathSelection::caller(PathProfile::InteractiveUser, None), 386 ) 387 .expect("contract"); 388 389 assert_eq!(contract.active_profile, "interactive_user"); 390 assert_eq!( 391 contract.allowed_profiles, 392 ["interactive_user", "service_host", "repo_local"] 393 ); 394 assert_eq!(contract.path_overrides.root_source, "host_defaults"); 395 assert_eq!( 396 contract.canonical_config_path, 397 PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml") 398 ); 399 assert_eq!( 400 contract.canonical_logs_dir, 401 PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd") 402 ); 403 assert_eq!( 404 contract.canonical_identity_path, 405 PathBuf::from( 406 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" 407 ) 408 ); 409 assert_eq!( 410 contract.canonical_transport_publish_database_path, 411 PathBuf::from( 412 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite" 413 ) 414 ); 415 } 416 }