lib

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

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 }