use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use crate::error::ServerError;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HomeSource {
Explicit,
Derived,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AionHome {
pub path: PathBuf,
pub source: HomeSource,
}
pub fn aion_home() -> Result<AionHome, ServerError> {
let configured = std::env::var_os("AION_HOME");
let (path, source) = match configured {
Some(value) if value.is_empty() => {
return Err(ServerError::Config {
message: "AION_HOME must not be empty".to_owned(),
});
}
Some(value) => (expand_tilde(Path::new(&value))?, HomeSource::Explicit),
None => {
let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) else {
return Err(ServerError::Config {
message: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
});
};
(PathBuf::from(home).join(".aion"), HomeSource::Derived)
}
};
let path = if path.is_absolute() {
path
} else {
std::env::current_dir()
.map(|current| current.join(path))
.map_err(|source| ServerError::Config {
message: format!(
"cannot resolve relative AION_HOME against the current directory: {source}"
),
})?
};
Ok(AionHome { path, source })
}
pub(super) fn expand_tilde(path: &Path) -> Result<PathBuf, ServerError> {
expand_tilde_against(path, std::env::var_os("HOME"))
}
fn expand_tilde_against(path: &Path, home: Option<OsString>) -> Result<PathBuf, ServerError> {
let mut components = path.components();
let Some(Component::Normal(first)) = components.next() else {
return Ok(path.to_owned());
};
if first == "~" {
let Some(home) = home.filter(|value| !value.is_empty()) else {
return Err(ServerError::Config {
message: format!(
"cannot expand `~` in path `{}`: HOME is not set",
path.display()
),
});
};
let rest = components.as_path();
return Ok(if rest.as_os_str().is_empty() {
PathBuf::from(home)
} else {
PathBuf::from(home).join(rest)
});
}
if first.as_encoded_bytes().starts_with(b"~") {
return Err(ServerError::Config {
message: format!(
"cannot resolve `{}`: `~user` expansion is not supported; use an absolute \
path (a directory literally named `{}` can be spelled `./{}`)",
path.display(),
Path::new(first).display(),
Path::new(first).display()
),
});
}
Ok(path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> OsString {
OsString::from("/users/probe")
}
#[test]
fn a_bare_tilde_expands_to_home() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
expand_tilde_against(Path::new("~"), Some(home()))?,
PathBuf::from("/users/probe")
);
Ok(())
}
#[test]
fn a_tilde_slash_prefix_expands_under_home() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
expand_tilde_against(Path::new("~/.aion/config.toml"), Some(home()))?,
PathBuf::from("/users/probe/.aion/config.toml")
);
Ok(())
}
#[test]
fn non_tilde_paths_pass_through_untouched() -> Result<(), Box<dyn std::error::Error>> {
for literal in ["/absolute/path", "relative/path", "./~escaped", ""] {
assert_eq!(
expand_tilde_against(Path::new(literal), Some(home()))?,
PathBuf::from(literal),
"`{literal}` must not be rewritten"
);
}
Ok(())
}
#[test]
fn an_interior_tilde_is_a_literal_name() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
expand_tilde_against(Path::new("/srv/~backup"), Some(home()))?,
PathBuf::from("/srv/~backup")
);
Ok(())
}
#[test]
fn a_tilde_user_form_is_a_loud_typed_refusal() -> Result<(), Box<dyn std::error::Error>> {
let error = expand_tilde_against(Path::new("~alice/.aion"), Some(home()))
.err()
.ok_or("`~alice` was accepted")?;
let message = error.to_string();
assert!(message.contains("~user"));
assert!(message.contains("~alice"));
assert!(message.contains("./~alice"));
Ok(())
}
#[test]
fn a_tilde_without_home_is_a_typed_error() -> Result<(), Box<dyn std::error::Error>> {
for absent in [None, Some(OsString::new())] {
let error = expand_tilde_against(Path::new("~/.aion"), absent)
.err()
.ok_or("`~` expanded without HOME")?;
assert!(error.to_string().contains("HOME is not set"));
}
Ok(())
}
}