pub mod user_dirs;
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
pub const DEFAULT_ORG: &str = "Orthic Labs";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AppDirsError {
#[error("cannot resolve the {what} folder: {why}")]
NoBase { what: &'static str, why: String },
#[error("cannot resolve the account's folders from the operating system: {why}")]
AccountHome { why: String },
#[error("Android has no environment-based folder resolution: pass the Context's filesDir and cacheDir to AppDirs::injected")]
AndroidNeedsInjectedRoots,
#[error("injected {what} folder is unusable: {why}")]
BadInjectedRoot { what: &'static str, why: String },
#[error("{0}")]
Io(#[from] io::Error),
}
pub type Result<T> = std::result::Result<T, AppDirsError>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Os {
Windows,
MacOs,
Linux,
Android,
}
impl Os {
pub fn current() -> Os {
if cfg!(windows) {
Os::Windows
} else if cfg!(target_os = "android") {
Os::Android
} else if cfg!(target_os = "macos") {
Os::MacOs
} else {
Os::Linux
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AppId {
pub name: &'static str,
pub org: Option<&'static str>,
pub legacy_names: &'static [&'static str],
}
impl AppId {
pub const fn new(name: &'static str) -> AppId {
AppId {
name,
org: Some(DEFAULT_ORG),
legacy_names: &[],
}
}
pub const fn with_legacy_names(mut self, names: &'static [&'static str]) -> AppId {
self.legacy_names = names;
self
}
pub const fn with_org(mut self, org: &'static str) -> AppId {
self.org = Some(org);
self
}
pub const fn without_org(mut self) -> AppId {
self.org = None;
self
}
pub fn namespace(&self, dev: bool) -> String {
if dev {
format!("{}Dev", self.name)
} else {
self.name.to_string()
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Options {
pub os: Os,
pub dev: bool,
}
impl Options {
pub fn new(os: Os) -> Options {
Options { os, dev: false }
}
pub fn current() -> Options {
Options {
os: Os::current(),
dev: false,
}
}
pub fn dev(mut self, dev: bool) -> Options {
self.dev = dev;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AppDirs {
pub data: PathBuf,
pub config: PathBuf,
pub local: PathBuf,
pub cache: PathBuf,
pub home: PathBuf,
}
impl AppDirs {
pub fn resolve(app: AppId) -> Result<AppDirs> {
resolve_native(&app, &Options::current(), &account_home, &|p| p.is_dir())
}
pub fn resolve_with_options(app: AppId, opts: &Options) -> Result<AppDirs> {
resolve_native(&app, opts, &account_home, &|p| p.is_dir())
}
pub fn resolve_with(
app: &AppId,
opts: &Options,
env: &dyn Fn(&str) -> Option<OsString>,
) -> Result<AppDirs> {
AppDirs::resolve_with_legacy(app, opts, env, &|_| false)
}
pub fn resolve_with_legacy(
app: &AppId,
opts: &Options,
env: &dyn Fn(&str) -> Option<OsString>,
exists: &dyn Fn(&Path) -> bool,
) -> Result<AppDirs> {
let ns = app.namespace(opts.dev);
if opts.os == Os::Android {
return Err(AppDirsError::AndroidNeedsInjectedRoots);
}
real_dirs(app, opts, &ns, env, exists)
}
pub fn injected(
files_dir: impl Into<PathBuf>,
cache_dir: impl Into<PathBuf>,
) -> Result<AppDirs> {
AppDirs::injected_for(Os::current(), files_dir.into(), cache_dir.into())
}
pub fn injected_for(os: Os, files_dir: PathBuf, cache_dir: PathBuf) -> Result<AppDirs> {
let bad = |what: &'static str, why: &str| AppDirsError::BadInjectedRoot {
what,
why: why.to_string(),
};
for (what, path) in [("files", &files_dir), ("cache", &cache_dir)] {
if path.as_os_str().is_empty() {
return Err(bad(what, "empty"));
}
if !looks_absolute(os, path) {
return Err(bad(what, "must be an absolute path without `..`"));
}
}
let win = os == Os::Windows;
if inside_or_equal(&files_dir, &cache_dir, win)
|| inside_or_equal(&cache_dir, &files_dir, win)
{
return Err(bad("cache", "must not equal or nest with the files folder"));
}
Ok(AppDirs {
data: files_dir.clone(),
config: files_dir.clone(),
local: files_dir.clone(),
cache: cache_dir,
home: files_dir,
})
}
pub fn migrate_dir(from: &Path, to: &Path) -> crate::error::Result<crate::migrate::Migrated> {
crate::migrate::migrate_dir(from, to)
}
pub fn create_all(&self) -> io::Result<()> {
for d in [
&self.data,
&self.config,
&self.local,
&self.cache,
&self.home,
] {
std::fs::create_dir_all(d)?;
}
Ok(())
}
}
struct Bases {
data: Option<PathBuf>,
config: Option<PathBuf>,
local: Option<PathBuf>,
cache: Option<PathBuf>,
home: Option<PathBuf>,
}
fn native_env(
key: &str,
#[cfg_attr(not(any(unix, windows)), allow(unused_variables))] account: &dyn Fn() -> Result<
PathBuf,
>,
) -> Option<OsString> {
match key {
#[cfg(unix)]
"HOME" => account().ok().map(PathBuf::into_os_string),
#[cfg(unix)]
"XDG_DATA_HOME" | "XDG_CACHE_HOME" | "XDG_CONFIG_HOME" => None,
#[cfg(windows)]
"APPDATA" => known::known_folder(&known::ROAMING_APP_DATA).map(PathBuf::into_os_string),
#[cfg(windows)]
"LOCALAPPDATA" => known::known_folder(&known::LOCAL_APP_DATA).map(PathBuf::into_os_string),
#[cfg(windows)]
"USERPROFILE" => account().ok().map(PathBuf::into_os_string),
_ => std::env::var_os(key),
}
}
fn resolve_native(
app: &AppId,
opts: &Options,
account: &dyn Fn() -> Result<PathBuf>,
exists: &dyn Fn(&Path) -> bool,
) -> Result<AppDirs> {
let env = |key: &str| native_env(key, account);
AppDirs::resolve_with_legacy(app, opts, &env, exists).map_err(|err| {
let AppDirsError::NoBase { what, .. } = &err else {
return err;
};
#[cfg(not(windows))]
let _ = what;
if opts.os != Os::current() {
return err;
}
#[cfg(unix)]
{
match account() {
Err(account_err) => account_err,
Ok(_) => err,
}
}
#[cfg(windows)]
{
AppDirsError::AccountHome {
why: format!(
"the known-folder lookup for the {what} folder failed or was not absolute"
),
}
}
#[cfg(not(any(unix, windows)))]
err
})
}
pub fn account_home() -> Result<PathBuf> {
#[cfg(unix)]
{
passwd_home()
}
#[cfg(windows)]
{
known::known_folder(&known::PROFILE)
.filter(|p| looks_absolute(Os::Windows, p))
.ok_or_else(|| AppDirsError::AccountHome {
why: "the Profile known folder is unavailable or not absolute".to_string(),
})
}
#[cfg(not(any(unix, windows)))]
{
Err(AppDirsError::AccountHome {
why: "no account lookup on this platform".to_string(),
})
}
}
#[cfg(unix)]
fn passwd_home() -> Result<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let fail = |why: &str| AppDirsError::AccountHome {
why: why.to_string(),
};
let mut size = 16 * 1024;
loop {
let mut buffer = vec![0u8; size];
let mut record = std::mem::MaybeUninit::<libc::passwd>::uninit();
let mut found = std::ptr::null_mut();
let status = unsafe {
libc::getpwuid_r(
libc::geteuid(),
record.as_mut_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut found,
)
};
if status == libc::ERANGE && size < 1024 * 1024 {
size *= 2;
continue;
}
if status != 0 {
return Err(fail("getpwuid_r failed"));
}
if found.is_null() {
return Err(fail("the account has no passwd entry"));
}
let raw = unsafe { record.assume_init().pw_dir };
if raw.is_null() {
return Err(fail("the passwd entry has no home directory"));
}
let bytes = unsafe { std::ffi::CStr::from_ptr(raw) }.to_bytes();
let home = PathBuf::from(std::ffi::OsStr::from_bytes(bytes));
if !looks_absolute(Os::current(), &home) {
return Err(fail("the passwd home is empty, relative or contains `..`"));
}
return Ok(home);
}
}
#[cfg_attr(not(windows), allow(dead_code))]
struct FolderCache<K> {
entries: std::sync::Mutex<Vec<(K, PathBuf)>>,
}
#[cfg_attr(not(windows), allow(dead_code))]
impl<K: PartialEq> FolderCache<K> {
const fn new() -> FolderCache<K> {
FolderCache {
entries: std::sync::Mutex::new(Vec::new()),
}
}
fn get_or_resolve(&self, key: K, resolve: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
let mut entries = self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, path)) = entries.iter().find(|(k, _)| *k == key) {
return Some(path.clone());
}
let path = resolve()?;
entries.push((key, path.clone()));
Some(path)
}
}
#[cfg(windows)]
mod known {
use std::ffi::c_void;
use std::os::windows::ffi::OsStringExt;
use std::path::PathBuf;
#[repr(C)]
#[derive(PartialEq, Eq)]
pub(super) struct Guid {
data1: u32,
data2: u16,
data3: u16,
data4: [u8; 8],
}
impl Guid {
fn key(&self) -> u128 {
let mut bytes = [0u8; 16];
bytes[..4].copy_from_slice(&self.data1.to_le_bytes());
bytes[4..6].copy_from_slice(&self.data2.to_le_bytes());
bytes[6..8].copy_from_slice(&self.data3.to_le_bytes());
bytes[8..].copy_from_slice(&self.data4);
u128::from_le_bytes(bytes)
}
}
pub(super) static ROAMING_APP_DATA: Guid = Guid {
data1: 0x3EB6_85DB,
data2: 0x65F9,
data3: 0x4CF6,
data4: [0xA0, 0x3A, 0xE3, 0xEF, 0x65, 0x72, 0x9F, 0x3D],
};
pub(super) static LOCAL_APP_DATA: Guid = Guid {
data1: 0xF1B3_2785,
data2: 0x6FBA,
data3: 0x4FCF,
data4: [0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91],
};
pub(super) static DESKTOP: Guid = Guid {
data1: 0xB4BF_CC3A,
data2: 0xDB2C,
data3: 0x424C,
data4: [0xB0, 0x29, 0x7F, 0xE9, 0x9A, 0x87, 0xC6, 0x41],
};
pub(super) static DOCUMENTS: Guid = Guid {
data1: 0xFDD3_9AD0,
data2: 0x238F,
data3: 0x46AF,
data4: [0xAD, 0xB4, 0x6C, 0x85, 0x48, 0x03, 0x69, 0xC7],
};
pub(super) static DOWNLOADS: Guid = Guid {
data1: 0x374D_E290,
data2: 0x123F,
data3: 0x4565,
data4: [0x91, 0x64, 0x39, 0xC4, 0x92, 0x5E, 0x46, 0x7B],
};
pub(super) static PROFILE: Guid = Guid {
data1: 0x5E6C_858F,
data2: 0x0E22,
data3: 0x4760,
data4: [0x9A, 0xFE, 0xEA, 0x33, 0x17, 0xB6, 0x71, 0x73],
};
#[link(name = "shell32")]
unsafe extern "system" {
fn SHGetKnownFolderPath(
rfid: *const Guid,
flags: u32,
token: *mut c_void,
path: *mut *mut u16,
) -> i32;
}
#[link(name = "ole32")]
unsafe extern "system" {
fn CoTaskMemFree(pv: *mut c_void);
}
const KF_FLAG_DONT_VERIFY: u32 = 0x4000;
fn api_folder(id: &Guid) -> Option<PathBuf> {
let mut raw: *mut u16 = std::ptr::null_mut();
let hr = unsafe {
SHGetKnownFolderPath(id, KF_FLAG_DONT_VERIFY, std::ptr::null_mut(), &mut raw)
};
if raw.is_null() {
return None;
}
let path = if hr >= 0 {
let mut len = 0usize;
while unsafe { *raw.add(len) } != 0 {
len += 1;
}
let wide = unsafe { std::slice::from_raw_parts(raw, len) };
Some(PathBuf::from(std::ffi::OsString::from_wide(wide)))
} else {
None
};
unsafe { CoTaskMemFree(raw.cast()) };
path.filter(|p| !p.as_os_str().is_empty())
}
#[link(name = "advapi32")]
unsafe extern "system" {
fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
fn RegOpenCurrentUser(sam: u32, key: *mut *mut c_void) -> i32;
fn RegCloseKey(key: *mut c_void) -> i32;
fn RegGetValueW(
key: *mut c_void,
subkey: *const u16,
value: *const u16,
flags: u32,
kind: *mut u32,
data: *mut c_void,
len: *mut u32,
) -> i32;
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentProcess() -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
}
#[link(name = "userenv")]
unsafe extern "system" {
fn ExpandEnvironmentStringsForUserW(
token: *mut c_void,
src: *const u16,
dest: *mut u16,
size: u32,
) -> i32;
}
const TOKEN_DUPLICATE: u32 = 0x2;
const TOKEN_IMPERSONATE: u32 = 0x4;
const TOKEN_QUERY: u32 = 0x8;
const KEY_READ: u32 = 0x2_0019;
const RRF_RT_REG_SZ: u32 = 0x2;
const RRF_RT_REG_EXPAND_SZ: u32 = 0x4;
const RRF_NOEXPAND: u32 = 0x1000_0000;
const ERROR_FILE_NOT_FOUND: i32 = 2;
const ERROR_MORE_DATA: i32 = 234;
const ERROR_INSUFFICIENT_BUFFER: i32 = 122;
const USER_SHELL_FOLDERS: &str =
r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders";
fn wide(text: &std::ffi::OsStr) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
text.encode_wide().chain(std::iter::once(0)).collect()
}
fn from_wide_nul(buffer: &[u16]) -> std::ffi::OsString {
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
std::ffi::OsString::from_wide(&buffer[..len])
}
pub(super) fn expand_template(template: &std::ffi::OsStr) -> Option<PathBuf> {
let mut token: *mut c_void = std::ptr::null_mut();
let ok = unsafe {
OpenProcessToken(
GetCurrentProcess(),
TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_IMPERSONATE,
&mut token,
)
};
if ok == 0 || token.is_null() {
return None;
}
let src = wide(template);
let mut size = 1024usize;
let result = loop {
let mut dest = vec![0u16; size];
let ok = unsafe {
ExpandEnvironmentStringsForUserW(
token,
src.as_ptr(),
dest.as_mut_ptr(),
size as u32,
)
};
if ok != 0 {
break Some(from_wide_nul(&dest));
}
let code = std::io::Error::last_os_error().raw_os_error();
if code == Some(ERROR_INSUFFICIENT_BUFFER) && size < 64 * 1024 {
size *= 2;
continue;
}
break None;
};
unsafe { CloseHandle(token) };
let path = PathBuf::from(result?);
let text = path.to_string_lossy();
(!text.contains('%') && super::looks_absolute(super::Os::Windows, &path)).then_some(path)
}
fn user_shell_folder_template(name: &str) -> Result<Option<std::ffi::OsString>, ()> {
let mut key: *mut c_void = std::ptr::null_mut();
if unsafe { RegOpenCurrentUser(KEY_READ, &mut key) } != 0 {
return Err(());
}
let subkey = wide(std::ffi::OsStr::new(USER_SHELL_FOLDERS));
let value = wide(std::ffi::OsStr::new(name));
let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
let mut bytes = 1024u32;
let result = loop {
let mut data = vec![0u16; (bytes as usize).div_ceil(2)];
let mut len = bytes;
let status = unsafe {
RegGetValueW(
key,
subkey.as_ptr(),
value.as_ptr(),
flags,
std::ptr::null_mut(),
data.as_mut_ptr().cast(),
&mut len,
)
};
match status {
0 => break Ok(Some(from_wide_nul(&data))),
ERROR_FILE_NOT_FOUND => break Ok(None),
ERROR_MORE_DATA if len > bytes && len <= 64 * 1024 => bytes = len,
_ => break Err(()),
}
};
unsafe { RegCloseKey(key) };
result
}
pub(super) fn known_folder(id: &Guid) -> Option<PathBuf> {
static CACHE: super::FolderCache<u128> = super::FolderCache::new();
CACHE.get_or_resolve(id.key(), || resolve_known_folder(id))
}
fn resolve_known_folder(id: &Guid) -> Option<PathBuf> {
let (value, default) = if *id == ROAMING_APP_DATA {
("AppData", r"AppData\Roaming")
} else if *id == LOCAL_APP_DATA {
("Local AppData", r"AppData\Local")
} else {
return api_folder(id);
};
match user_shell_folder_template(value).ok()? {
Some(template) if !template.is_empty() => expand_template(&template),
Some(_) => None,
None => Some(api_folder(&PROFILE)?.join(default)),
}
}
}
fn nonempty(env: &dyn Fn(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
env(key)
.filter(|v| !v.to_string_lossy().trim().is_empty())
.map(PathBuf::from)
}
fn absolute_env(os: Os, env: &dyn Fn(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
nonempty(env, key).filter(|p| looks_absolute(os, p))
}
impl Bases {
fn read(os: Os, env: &dyn Fn(&str) -> Option<OsString>) -> Bases {
match os {
Os::Windows => Bases {
data: absolute_env(os, env, "APPDATA"),
config: absolute_env(os, env, "APPDATA"),
local: absolute_env(os, env, "LOCALAPPDATA"),
cache: absolute_env(os, env, "LOCALAPPDATA"),
home: absolute_env(os, env, "USERPROFILE"),
},
Os::MacOs => {
let home = absolute_env(os, env, "HOME");
let support = home.as_ref().map(|h| h.join("Library/Application Support"));
Bases {
data: support.clone(),
config: support.clone(),
local: support,
cache: home.as_ref().map(|h| h.join("Library/Caches")),
home,
}
}
Os::Android => Bases {
data: None,
config: None,
local: None,
cache: None,
home: None,
},
Os::Linux => {
let home = absolute_env(os, env, "HOME");
let data = absolute_env(os, env, "XDG_DATA_HOME")
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let cache = absolute_env(os, env, "XDG_CACHE_HOME")
.or_else(|| home.as_ref().map(|h| h.join(".cache")));
let config = absolute_env(os, env, "XDG_CONFIG_HOME")
.or_else(|| home.as_ref().map(|h| h.join(".config")))
.or_else(|| data.clone());
Bases {
local: data.clone(),
config,
data,
cache,
home,
}
}
}
}
}
fn app_dir(base: &Path, app: &AppId, ns: &str) -> PathBuf {
match app.org {
Some(org) => base.join(org).join(ns),
None => base.join(ns),
}
}
fn missing(os: Os, what: &'static str) -> AppDirsError {
let why = match os {
Os::Windows => match what {
"data" | "config" => "APPDATA is not set to an absolute path",
"home" => "USERPROFILE is not set to an absolute path",
_ => "LOCALAPPDATA is not set to an absolute path",
},
_ => "HOME is not set to an absolute path",
};
AppDirsError::NoBase {
what,
why: why.to_string(),
}
}
fn pick_app_dir(
base: &Path,
app: &AppId,
opts: &Options,
ns: &str,
exists: &dyn Fn(&Path) -> bool,
) -> PathBuf {
let current = app_dir(base, app, ns);
if exists(¤t) {
return current;
}
for legacy in app.legacy_names {
let name = if opts.dev {
format!("{legacy}Dev")
} else {
(*legacy).to_string()
};
let candidate = app_dir(base, app, &name);
if exists(&candidate) {
return candidate;
}
}
current
}
fn real_dirs(
app: &AppId,
opts: &Options,
ns: &str,
env: &dyn Fn(&str) -> Option<OsString>,
exists: &dyn Fn(&Path) -> bool,
) -> Result<AppDirs> {
let os = opts.os;
let b = Bases::read(os, env);
let data = b.data.ok_or_else(|| missing(os, "data"))?;
let config = b.config.ok_or_else(|| missing(os, "config"))?;
let local = b.local.ok_or_else(|| missing(os, "local"))?;
let cache = b.cache.ok_or_else(|| missing(os, "cache"))?;
let home = b.home.ok_or_else(|| missing(os, "home"))?;
let cache = if os == Os::Windows {
app_dir(&cache, app, ns).join("Cache")
} else {
app_dir(&cache, app, ns)
};
Ok(AppDirs {
data: pick_app_dir(&data, app, opts, ns, exists),
config: pick_app_dir(&config, app, opts, ns, exists),
local: pick_app_dir(&local, app, opts, ns, exists),
cache,
home,
})
}
fn looks_absolute(os: Os, path: &Path) -> bool {
let s = path.to_string_lossy();
let rooted = match os {
Os::Windows => {
let b = s.as_bytes();
s.starts_with("\\\\")
|| (b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/'))
}
_ => s.starts_with('/'),
};
rooted
&& !components(&s, os == Os::Windows, false)
.iter()
.any(|c| c == "..")
}
fn components(s: &str, windows: bool, lower: bool) -> Vec<String> {
s.split(|c: char| c == '/' || (windows && c == '\\'))
.filter(|c| !c.is_empty())
.map(|c| {
if lower {
c.to_lowercase()
} else {
c.to_string()
}
})
.collect()
}
fn inside_or_equal(path: &Path, base: &Path, windows: bool) -> bool {
let seps = windows || cfg!(windows);
let p = components(&path.to_string_lossy(), seps, windows);
let b = components(&base.to_string_lossy(), seps, windows);
p.len() >= b.len() && p[..b.len()] == b[..]
}