app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

paths.rs (22014B)


      1 use std::{
      2     env,
      3     error::Error,
      4     ffi::OsString,
      5     fmt,
      6     path::{Path, PathBuf},
      7 };
      8 
      9 pub use radroots_runtime_paths::{
     10     DEFAULT_SHARED_LOCAL_EVENTS_DB_FILE_NAME as SHARED_LOCAL_EVENTS_DB_FILE_NAME,
     11     DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE as SHARED_LOCAL_EVENTS_NAMESPACE,
     12     DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_KIND as SHARED_LOCAL_EVENTS_NAMESPACE_KIND,
     13     DEFAULT_SHARED_LOCAL_EVENTS_NAMESPACE_VALUE as SHARED_LOCAL_EVENTS_NAMESPACE_VALUE,
     14 };
     15 use radroots_runtime_paths::{
     16     default_shared_local_events_database_path_from_data_root,
     17     default_shared_local_events_database_path_from_shared_accounts_data_root,
     18 };
     19 
     20 pub const APP_RUNTIME_NAMESPACE_KIND: &str = "apps";
     21 pub const APP_RUNTIME_NAMESPACE_VALUE: &str = "app";
     22 pub const APP_RUNTIME_NAMESPACE: &str = "apps/app";
     23 pub const SHARED_ACCOUNTS_NAMESPACE_KIND: &str = "shared";
     24 pub const SHARED_ACCOUNTS_NAMESPACE_VALUE: &str = "accounts";
     25 pub const SHARED_ACCOUNTS_NAMESPACE: &str = "shared/accounts";
     26 pub const SHARED_ACCOUNTS_STORE_FILE_NAME: &str = "store.json";
     27 pub const SHARED_IDENTITIES_NAMESPACE_KIND: &str = "shared";
     28 pub const SHARED_IDENTITIES_NAMESPACE_VALUE: &str = "identities";
     29 pub const SHARED_IDENTITIES_NAMESPACE: &str = "shared/identities";
     30 pub const SHARED_IDENTITY_FILE_NAME: &str = "default.json";
     31 pub const APP_PATHS_PROFILE_ENV: &str = "RADROOTS_APP_PATHS_PROFILE";
     32 pub const APP_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RADROOTS_APP_PATHS_REPO_LOCAL_ROOT";
     33 
     34 const APP_INTERACTIVE_USER_PROFILE: &str = "interactive_user";
     35 const APP_REPO_LOCAL_PROFILE: &str = "repo_local";
     36 
     37 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     38 pub enum AppRuntimePlatform {
     39     Linux,
     40     Macos,
     41     Windows,
     42     Other(&'static str),
     43 }
     44 
     45 impl AppRuntimePlatform {
     46     pub fn current() -> Self {
     47         match env::consts::OS {
     48             "linux" => Self::Linux,
     49             "macos" => Self::Macos,
     50             "windows" => Self::Windows,
     51             other => Self::Other(other),
     52         }
     53     }
     54 
     55     pub const fn label(self) -> &'static str {
     56         match self {
     57             Self::Linux => "linux",
     58             Self::Macos => "macos",
     59             Self::Windows => "windows",
     60             Self::Other(other) => other,
     61         }
     62     }
     63 }
     64 
     65 #[derive(Clone, Debug, Default, Eq, PartialEq)]
     66 pub struct AppRuntimeHostEnvironment {
     67     pub home_dir: Option<PathBuf>,
     68     pub appdata_dir: Option<PathBuf>,
     69     pub localappdata_dir: Option<PathBuf>,
     70     pub paths_profile: Option<String>,
     71     pub repo_local_root: Option<PathBuf>,
     72 }
     73 
     74 impl AppRuntimeHostEnvironment {
     75     pub fn from_current_process() -> Self {
     76         Self::from_env_reader(|name| env::var_os(name))
     77     }
     78 
     79     pub fn from_env_reader<F>(mut read_env: F) -> Self
     80     where
     81         F: FnMut(&str) -> Option<OsString>,
     82     {
     83         Self {
     84             home_dir: read_env("HOME").map(PathBuf::from),
     85             appdata_dir: read_env("APPDATA").map(PathBuf::from),
     86             localappdata_dir: read_env("LOCALAPPDATA").map(PathBuf::from),
     87             paths_profile: read_env(APP_PATHS_PROFILE_ENV)
     88                 .map(|value| value.to_string_lossy().into_owned()),
     89             repo_local_root: read_env(APP_PATHS_REPO_LOCAL_ROOT_ENV).map(PathBuf::from),
     90         }
     91     }
     92 }
     93 
     94 #[derive(Clone, Debug, Eq, PartialEq)]
     95 pub struct AppRuntimeRoots {
     96     pub config: PathBuf,
     97     pub data: PathBuf,
     98     pub cache: PathBuf,
     99     pub logs: PathBuf,
    100     pub run: PathBuf,
    101     pub secrets: PathBuf,
    102 }
    103 
    104 #[derive(Clone, Debug, Eq, PartialEq)]
    105 pub struct AppSharedAccountsPaths {
    106     pub data_root: PathBuf,
    107     pub secrets_root: PathBuf,
    108     pub store_path: PathBuf,
    109 }
    110 
    111 #[derive(Clone, Debug, Eq, PartialEq)]
    112 pub struct AppSharedIdentityPaths {
    113     pub default_identity_path: PathBuf,
    114 }
    115 
    116 #[derive(Clone, Debug, Eq, PartialEq)]
    117 pub struct AppDesktopRuntimePaths {
    118     pub app: AppRuntimeRoots,
    119     pub shared_accounts: AppSharedAccountsPaths,
    120     pub shared_identity: AppSharedIdentityPaths,
    121 }
    122 
    123 impl AppSharedAccountsPaths {
    124     pub fn shared_local_events_database_path(&self) -> Option<PathBuf> {
    125         shared_local_events_database_path_from_shared_accounts(self)
    126     }
    127 }
    128 
    129 impl AppRuntimeRoots {
    130     pub fn current_desktop() -> Result<Self, AppRuntimePathsError> {
    131         AppDesktopRuntimePaths::current_desktop().map(|paths| paths.app)
    132     }
    133 
    134     pub fn for_desktop(
    135         platform: AppRuntimePlatform,
    136         host_environment: AppRuntimeHostEnvironment,
    137     ) -> Result<Self, AppRuntimePathsError> {
    138         Ok(resolve_desktop_base_roots(platform, host_environment)?.namespaced_app())
    139     }
    140 
    141     pub fn from_base_root(base_root: impl AsRef<Path>) -> Self {
    142         let base_root = base_root.as_ref();
    143         Self {
    144             config: base_root.join("config"),
    145             data: base_root.join("data"),
    146             cache: base_root.join("cache"),
    147             logs: base_root.join("logs"),
    148             run: base_root.join("run"),
    149             secrets: base_root.join("secrets"),
    150         }
    151     }
    152 
    153     pub fn namespaced_app(&self) -> Self {
    154         self.namespaced(APP_RUNTIME_NAMESPACE_KIND, APP_RUNTIME_NAMESPACE_VALUE)
    155     }
    156 
    157     fn namespaced_shared(&self, value: &str) -> Self {
    158         self.namespaced(SHARED_ACCOUNTS_NAMESPACE_KIND, value)
    159     }
    160 
    161     fn namespaced(&self, kind: &str, value: &str) -> Self {
    162         let namespace = PathBuf::from(kind).join(value);
    163         Self {
    164             config: self.config.join(&namespace),
    165             data: self.data.join(&namespace),
    166             cache: self.cache.join(&namespace),
    167             logs: self.logs.join(&namespace),
    168             run: self.run.join(&namespace),
    169             secrets: self.secrets.join(namespace),
    170         }
    171     }
    172 }
    173 
    174 impl AppDesktopRuntimePaths {
    175     pub fn current_desktop() -> Result<Self, AppRuntimePathsError> {
    176         Self::for_desktop(
    177             AppRuntimePlatform::current(),
    178             AppRuntimeHostEnvironment::from_current_process(),
    179         )
    180     }
    181 
    182     pub fn for_desktop(
    183         platform: AppRuntimePlatform,
    184         host_environment: AppRuntimeHostEnvironment,
    185     ) -> Result<Self, AppRuntimePathsError> {
    186         let base_roots = resolve_desktop_base_roots(platform, host_environment)?;
    187         let shared_accounts = base_roots.namespaced_shared(SHARED_ACCOUNTS_NAMESPACE_VALUE);
    188         let shared_identity = base_roots.namespaced_shared(SHARED_IDENTITIES_NAMESPACE_VALUE);
    189 
    190         Ok(Self {
    191             app: base_roots.namespaced_app(),
    192             shared_accounts: AppSharedAccountsPaths {
    193                 data_root: shared_accounts.data.clone(),
    194                 secrets_root: shared_accounts.secrets.clone(),
    195                 store_path: shared_accounts.data.join(SHARED_ACCOUNTS_STORE_FILE_NAME),
    196             },
    197             shared_identity: AppSharedIdentityPaths {
    198                 default_identity_path: shared_identity.secrets.join(SHARED_IDENTITY_FILE_NAME),
    199             },
    200         })
    201     }
    202 
    203     pub fn shared_local_events_database_path(&self) -> Result<PathBuf, AppRuntimePathsError> {
    204         let data_root = self
    205             .app
    206             .data
    207             .parent()
    208             .and_then(|apps_root| apps_root.parent())
    209             .ok_or(AppRuntimePathsError::SharedLocalEventsPath)?;
    210 
    211         Ok(shared_local_events_database_path_from_data_root(data_root))
    212     }
    213 }
    214 
    215 pub fn shared_local_events_database_path_from_shared_accounts(
    216     paths: &AppSharedAccountsPaths,
    217 ) -> Option<PathBuf> {
    218     default_shared_local_events_database_path_from_shared_accounts_data_root(&paths.data_root).ok()
    219 }
    220 
    221 fn shared_local_events_database_path_from_data_root(data_root: &Path) -> PathBuf {
    222     default_shared_local_events_database_path_from_data_root(data_root)
    223 }
    224 
    225 fn resolve_desktop_base_roots(
    226     platform: AppRuntimePlatform,
    227     host_environment: AppRuntimeHostEnvironment,
    228 ) -> Result<AppRuntimeRoots, AppRuntimePathsError> {
    229     let roots = match resolve_desktop_profile(host_environment.paths_profile.as_deref())? {
    230         AppDesktopPathProfile::InteractiveUser => resolve_interactive_user_roots(
    231             platform,
    232             host_environment.home_dir,
    233             host_environment.appdata_dir,
    234             host_environment.localappdata_dir,
    235         )?,
    236         AppDesktopPathProfile::RepoLocal => {
    237             let repo_local_root = host_environment
    238                 .repo_local_root
    239                 .ok_or(AppRuntimePathsError::MissingRepoLocalRoot)?;
    240             if repo_local_root.as_os_str().is_empty() {
    241                 return Err(AppRuntimePathsError::EmptyRepoLocalRoot);
    242             }
    243             AppRuntimeRoots::from_base_root(repo_local_root)
    244         }
    245     };
    246 
    247     Ok(roots)
    248 }
    249 
    250 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    251 enum AppDesktopPathProfile {
    252     InteractiveUser,
    253     RepoLocal,
    254 }
    255 
    256 fn resolve_desktop_profile(
    257     profile: Option<&str>,
    258 ) -> Result<AppDesktopPathProfile, AppRuntimePathsError> {
    259     match profile {
    260         None => Ok(AppDesktopPathProfile::InteractiveUser),
    261         Some(value) => match value.trim().to_ascii_lowercase().as_str() {
    262             APP_INTERACTIVE_USER_PROFILE => Ok(AppDesktopPathProfile::InteractiveUser),
    263             APP_REPO_LOCAL_PROFILE => Ok(AppDesktopPathProfile::RepoLocal),
    264             _ => Err(AppRuntimePathsError::UnsupportedPathProfile {
    265                 value: value.to_owned(),
    266             }),
    267         },
    268     }
    269 }
    270 
    271 fn resolve_interactive_user_roots(
    272     platform: AppRuntimePlatform,
    273     home_dir: Option<PathBuf>,
    274     appdata_dir: Option<PathBuf>,
    275     localappdata_dir: Option<PathBuf>,
    276 ) -> Result<AppRuntimeRoots, AppRuntimePathsError> {
    277     match platform {
    278         AppRuntimePlatform::Linux | AppRuntimePlatform::Macos => {
    279             let home_dir = home_dir.ok_or(AppRuntimePathsError::MissingHomeDir { platform })?;
    280             Ok(AppRuntimeRoots::from_base_root(home_dir.join(".radroots")))
    281         }
    282         AppRuntimePlatform::Windows => {
    283             let appdata_dir = appdata_dir.ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?;
    284             let localappdata_dir =
    285                 localappdata_dir.ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?;
    286             let config_root = appdata_dir.join("Radroots");
    287             let local_root = localappdata_dir.join("Radroots");
    288             Ok(AppRuntimeRoots {
    289                 config: config_root.join("config"),
    290                 data: local_root.join("data"),
    291                 cache: local_root.join("cache"),
    292                 logs: local_root.join("logs"),
    293                 run: local_root.join("run"),
    294                 secrets: config_root.join("secrets"),
    295             })
    296         }
    297         AppRuntimePlatform::Other(_) => Err(AppRuntimePathsError::UnsupportedPlatform { platform }),
    298     }
    299 }
    300 
    301 #[derive(Clone, Debug, Eq, PartialEq)]
    302 pub enum AppRuntimePathsError {
    303     MissingHomeDir { platform: AppRuntimePlatform },
    304     MissingWindowsUserDirs,
    305     MissingRepoLocalRoot,
    306     EmptyRepoLocalRoot,
    307     UnsupportedPathProfile { value: String },
    308     UnsupportedPlatform { platform: AppRuntimePlatform },
    309     SharedLocalEventsPath,
    310 }
    311 
    312 impl fmt::Display for AppRuntimePathsError {
    313     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    314         match self {
    315             Self::MissingHomeDir { platform } => {
    316                 write!(
    317                     formatter,
    318                     "desktop runtime roots require HOME for {}",
    319                     platform.label()
    320                 )
    321             }
    322             Self::MissingWindowsUserDirs => formatter
    323                 .write_str("desktop runtime roots require APPDATA and LOCALAPPDATA on windows"),
    324             Self::MissingRepoLocalRoot => write!(
    325                 formatter,
    326                 "desktop runtime roots require {APP_PATHS_REPO_LOCAL_ROOT_ENV} when {APP_PATHS_PROFILE_ENV}=repo_local"
    327             ),
    328             Self::EmptyRepoLocalRoot => write!(
    329                 formatter,
    330                 "{APP_PATHS_REPO_LOCAL_ROOT_ENV} must not be empty when {APP_PATHS_PROFILE_ENV}=repo_local"
    331             ),
    332             Self::UnsupportedPathProfile { value } => write!(
    333                 formatter,
    334                 "{APP_PATHS_PROFILE_ENV} must be `interactive_user` or `repo_local`, got `{value}`"
    335             ),
    336             Self::UnsupportedPlatform { platform } => write!(
    337                 formatter,
    338                 "desktop runtime roots are unsupported on {}",
    339                 platform.label()
    340             ),
    341             Self::SharedLocalEventsPath => formatter
    342                 .write_str("desktop app data root must be nested under the Radroots data root"),
    343         }
    344     }
    345 }
    346 
    347 impl Error for AppRuntimePathsError {}
    348 
    349 #[cfg(test)]
    350 mod tests {
    351     use std::{collections::BTreeMap, ffi::OsString, path::PathBuf};
    352 
    353     use super::{
    354         APP_PATHS_PROFILE_ENV, APP_PATHS_REPO_LOCAL_ROOT_ENV, APP_RUNTIME_NAMESPACE,
    355         AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePathsError,
    356         AppRuntimePlatform, AppRuntimeRoots, SHARED_ACCOUNTS_NAMESPACE,
    357         SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITIES_NAMESPACE, SHARED_IDENTITY_FILE_NAME,
    358         SHARED_LOCAL_EVENTS_DB_FILE_NAME, SHARED_LOCAL_EVENTS_NAMESPACE,
    359     };
    360 
    361     #[test]
    362     fn desktop_runtime_roots_use_canonical_macos_namespace() {
    363         let paths = AppDesktopRuntimePaths::for_desktop(
    364             AppRuntimePlatform::Macos,
    365             AppRuntimeHostEnvironment {
    366                 home_dir: Some(PathBuf::from("/Users/treesap")),
    367                 ..AppRuntimeHostEnvironment::default()
    368             },
    369         )
    370         .expect("macos roots should resolve");
    371 
    372         assert_eq!(
    373             paths.app.data,
    374             PathBuf::from("/Users/treesap/.radroots/data").join(APP_RUNTIME_NAMESPACE)
    375         );
    376         assert_eq!(
    377             paths.app.logs,
    378             PathBuf::from("/Users/treesap/.radroots/logs").join(APP_RUNTIME_NAMESPACE)
    379         );
    380         assert_eq!(
    381             paths.shared_accounts.data_root,
    382             PathBuf::from("/Users/treesap/.radroots/data").join(SHARED_ACCOUNTS_NAMESPACE)
    383         );
    384         assert_eq!(
    385             paths.shared_accounts.secrets_root,
    386             PathBuf::from("/Users/treesap/.radroots/secrets").join(SHARED_ACCOUNTS_NAMESPACE)
    387         );
    388         assert_eq!(
    389             paths.shared_accounts.store_path,
    390             PathBuf::from("/Users/treesap/.radroots/data")
    391                 .join(SHARED_ACCOUNTS_NAMESPACE)
    392                 .join(SHARED_ACCOUNTS_STORE_FILE_NAME)
    393         );
    394         assert_eq!(
    395             paths.shared_identity.default_identity_path,
    396             PathBuf::from("/Users/treesap/.radroots/secrets")
    397                 .join(SHARED_IDENTITIES_NAMESPACE)
    398                 .join(SHARED_IDENTITY_FILE_NAME)
    399         );
    400         assert_eq!(
    401             paths
    402                 .shared_local_events_database_path()
    403                 .expect("shared local events path"),
    404             PathBuf::from("/Users/treesap/.radroots/data")
    405                 .join(SHARED_LOCAL_EVENTS_NAMESPACE)
    406                 .join(SHARED_LOCAL_EVENTS_DB_FILE_NAME)
    407         );
    408     }
    409 
    410     #[test]
    411     fn desktop_runtime_roots_use_canonical_linux_namespace() {
    412         let roots = AppRuntimeRoots::for_desktop(
    413             AppRuntimePlatform::Linux,
    414             AppRuntimeHostEnvironment {
    415                 home_dir: Some(PathBuf::from("/home/treesap")),
    416                 ..AppRuntimeHostEnvironment::default()
    417             },
    418         )
    419         .expect("linux roots should resolve");
    420 
    421         assert_eq!(
    422             roots.data,
    423             PathBuf::from("/home/treesap/.radroots/data").join(APP_RUNTIME_NAMESPACE)
    424         );
    425         assert_eq!(
    426             roots.logs,
    427             PathBuf::from("/home/treesap/.radroots/logs").join(APP_RUNTIME_NAMESPACE)
    428         );
    429     }
    430 
    431     #[test]
    432     fn desktop_runtime_roots_use_native_windows_roots() {
    433         let roots = AppRuntimeRoots::for_desktop(
    434             AppRuntimePlatform::Windows,
    435             AppRuntimeHostEnvironment {
    436                 appdata_dir: Some(PathBuf::from(r"C:\Users\treesap\AppData\Roaming")),
    437                 localappdata_dir: Some(PathBuf::from(r"C:\Users\treesap\AppData\Local")),
    438                 ..AppRuntimeHostEnvironment::default()
    439             },
    440         )
    441         .expect("windows roots should resolve");
    442 
    443         assert_eq!(
    444             roots.config,
    445             PathBuf::from(r"C:\Users\treesap\AppData\Roaming")
    446                 .join("Radroots")
    447                 .join("config")
    448                 .join(APP_RUNTIME_NAMESPACE)
    449         );
    450         assert_eq!(
    451             roots.data,
    452             PathBuf::from(r"C:\Users\treesap\AppData\Local")
    453                 .join("Radroots")
    454                 .join("data")
    455                 .join(APP_RUNTIME_NAMESPACE)
    456         );
    457     }
    458 
    459     #[test]
    460     fn desktop_runtime_roots_use_explicit_repo_local_root() {
    461         let paths = AppDesktopRuntimePaths::for_desktop(
    462             AppRuntimePlatform::Macos,
    463             AppRuntimeHostEnvironment {
    464                 paths_profile: Some("repo_local".to_owned()),
    465                 repo_local_root: Some(PathBuf::from("/repo/infra/local/runtime/radroots")),
    466                 ..AppRuntimeHostEnvironment::default()
    467             },
    468         )
    469         .expect("repo-local roots should resolve");
    470 
    471         assert_eq!(
    472             paths.app.data,
    473             PathBuf::from("/repo/infra/local/runtime/radroots/data/apps/app")
    474         );
    475         assert_eq!(
    476             paths.app.logs,
    477             PathBuf::from("/repo/infra/local/runtime/radroots/logs/apps/app")
    478         );
    479         assert_eq!(
    480             paths.shared_accounts.data_root,
    481             PathBuf::from("/repo/infra/local/runtime/radroots/data/shared/accounts")
    482         );
    483         assert_eq!(
    484             paths.shared_identity.default_identity_path,
    485             PathBuf::from("/repo/infra/local/runtime/radroots/secrets/shared/identities")
    486                 .join(SHARED_IDENTITY_FILE_NAME)
    487         );
    488         assert_eq!(
    489             paths
    490                 .shared_accounts
    491                 .shared_local_events_database_path()
    492                 .expect("shared local events path"),
    493             PathBuf::from("/repo/infra/local/runtime/radroots/data")
    494                 .join(SHARED_LOCAL_EVENTS_NAMESPACE)
    495                 .join(SHARED_LOCAL_EVENTS_DB_FILE_NAME)
    496         );
    497     }
    498 
    499     #[test]
    500     fn host_environment_can_resolve_from_env_reader() {
    501         let env = BTreeMap::from([
    502             (APP_PATHS_PROFILE_ENV, OsString::from("repo_local")),
    503             (
    504                 APP_PATHS_REPO_LOCAL_ROOT_ENV,
    505                 OsString::from("/repo/infra/local/runtime/radroots"),
    506             ),
    507         ]);
    508         let paths = AppDesktopRuntimePaths::for_desktop(
    509             AppRuntimePlatform::Linux,
    510             AppRuntimeHostEnvironment::from_env_reader(|name| env.get(name).cloned()),
    511         )
    512         .expect("repo-local env-backed roots should resolve");
    513 
    514         assert_eq!(
    515             paths.app.data,
    516             PathBuf::from("/repo/infra/local/runtime/radroots/data/apps/app")
    517         );
    518     }
    519 
    520     #[test]
    521     fn repo_local_profile_requires_explicit_root() {
    522         let err = AppRuntimeRoots::for_desktop(
    523             AppRuntimePlatform::Macos,
    524             AppRuntimeHostEnvironment {
    525                 paths_profile: Some("repo_local".to_owned()),
    526                 ..AppRuntimeHostEnvironment::default()
    527             },
    528         )
    529         .expect_err("repo-local root should be required");
    530 
    531         assert_eq!(err, AppRuntimePathsError::MissingRepoLocalRoot);
    532     }
    533 
    534     #[test]
    535     fn unsupported_path_profile_is_rejected() {
    536         let err = AppRuntimeRoots::for_desktop(
    537             AppRuntimePlatform::Macos,
    538             AppRuntimeHostEnvironment {
    539                 paths_profile: Some("dev".to_owned()),
    540                 ..AppRuntimeHostEnvironment::default()
    541             },
    542         )
    543         .expect_err("unsupported profile should fail");
    544 
    545         assert_eq!(
    546             err,
    547             AppRuntimePathsError::UnsupportedPathProfile {
    548                 value: "dev".to_owned(),
    549             }
    550         );
    551     }
    552 
    553     #[cfg(unix)]
    554     #[test]
    555     fn malformed_env_profile_fails_closed() {
    556         use std::os::unix::ffi::OsStringExt;
    557 
    558         let env = BTreeMap::from([(
    559             APP_PATHS_PROFILE_ENV,
    560             OsString::from_vec(vec![0xff, b'd', b'e', b'v']),
    561         )]);
    562         let err = AppRuntimeRoots::for_desktop(
    563             AppRuntimePlatform::Macos,
    564             AppRuntimeHostEnvironment::from_env_reader(|name| env.get(name).cloned()),
    565         )
    566         .expect_err("malformed configured profile should fail closed");
    567 
    568         match err {
    569             AppRuntimePathsError::UnsupportedPathProfile { value } => {
    570                 assert!(value.contains('\u{fffd}'));
    571                 assert!(value.ends_with("dev"));
    572             }
    573             unexpected => panic!("unexpected malformed profile error: {unexpected:?}"),
    574         }
    575     }
    576 
    577     #[test]
    578     fn desktop_runtime_roots_require_home_dir_on_unix() {
    579         let err = AppRuntimeRoots::for_desktop(
    580             AppRuntimePlatform::Macos,
    581             AppRuntimeHostEnvironment::default(),
    582         )
    583         .expect_err("missing home dir should fail");
    584 
    585         assert_eq!(
    586             err,
    587             AppRuntimePathsError::MissingHomeDir {
    588                 platform: AppRuntimePlatform::Macos,
    589             }
    590         );
    591     }
    592 
    593     #[test]
    594     fn desktop_runtime_roots_require_windows_user_dirs() {
    595         let err = AppRuntimeRoots::for_desktop(
    596             AppRuntimePlatform::Windows,
    597             AppRuntimeHostEnvironment {
    598                 appdata_dir: Some(PathBuf::from(r"C:\Users\treesap\AppData\Roaming")),
    599                 ..AppRuntimeHostEnvironment::default()
    600             },
    601         )
    602         .expect_err("missing local appdata should fail");
    603 
    604         assert_eq!(err, AppRuntimePathsError::MissingWindowsUserDirs);
    605     }
    606 
    607     #[test]
    608     fn desktop_runtime_roots_reject_unsupported_platforms() {
    609         let err = AppRuntimeRoots::for_desktop(
    610             AppRuntimePlatform::Other("freebsd"),
    611             AppRuntimeHostEnvironment::default(),
    612         )
    613         .expect_err("unsupported platform should fail");
    614 
    615         assert_eq!(
    616             err,
    617             AppRuntimePathsError::UnsupportedPlatform {
    618                 platform: AppRuntimePlatform::Other("freebsd"),
    619             }
    620         );
    621     }
    622 }