Skip to main content

aion_server/config/
home.rs

1//! Resolution of the server's central user-level configuration and state root.
2
3use std::ffi::OsString;
4use std::path::{Component, Path, PathBuf};
5
6use crate::error::ServerError;
7
8/// Where a resolved Aion home came from.
9///
10/// This is load-bearing, not diagnostic. Setting `AION_HOME` is how an operator
11/// says "this server's state lives here and nowhere else", so every later
12/// default that could point state somewhere else has to be able to see that the
13/// operator already answered the question. Resolving the home without carrying
14/// this fact is what let a working-directory legacy default silently win over
15/// an explicitly relocated home.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum HomeSource {
18    /// `AION_HOME` was set: the operator named this server's state root.
19    Explicit,
20    /// `AION_HOME` was absent: the home was derived as `$HOME/.aion`.
21    Derived,
22}
23
24/// A resolved Aion home together with the provenance of that resolution.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct AionHome {
27    /// The absolute home directory. Not created: config discovery is read-only.
28    pub path: PathBuf,
29    /// Whether the operator named the home or it was derived from `$HOME`.
30    pub source: HomeSource,
31}
32
33/// Resolve the Aion user-level configuration and state directory.
34///
35/// `AION_HOME` wins when set. Otherwise the directory is `$HOME/.aion`. The
36/// returned path is absolute, but is not created: config discovery is read-only,
37/// and the store or authoring surface creates it only on first write.
38///
39/// The returned [`AionHome::source`] records which of those two happened.
40/// Callers that choose defaults *below* the home must consult it rather than
41/// re-reading `AION_HOME` themselves: the environment is not guaranteed to be
42/// the same by then, and a second reading is a second answer.
43///
44/// # Errors
45///
46/// Returns [`ServerError::Config`] when `AION_HOME` is empty, neither
47/// `AION_HOME` nor `HOME` can identify a home directory, a `~`-prefixed
48/// `AION_HOME` cannot be expanded (see [`expand_tilde`]), or a relative
49/// `AION_HOME` cannot be resolved because the current directory is unavailable.
50pub fn aion_home() -> Result<AionHome, ServerError> {
51    let configured = std::env::var_os("AION_HOME");
52    let (path, source) = match configured {
53        Some(value) if value.is_empty() => {
54            return Err(ServerError::Config {
55                message: "AION_HOME must not be empty".to_owned(),
56            });
57        }
58        Some(value) => (expand_tilde(Path::new(&value))?, HomeSource::Explicit),
59        None => {
60            let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) else {
61                return Err(ServerError::Config {
62                    message: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
63                });
64            };
65            (PathBuf::from(home).join(".aion"), HomeSource::Derived)
66        }
67    };
68    let path = if path.is_absolute() {
69        path
70    } else {
71        std::env::current_dir()
72            .map(|current| current.join(path))
73            .map_err(|source| ServerError::Config {
74                message: format!(
75                    "cannot resolve relative AION_HOME against the current directory: {source}"
76                ),
77            })?
78    };
79    Ok(AionHome { path, source })
80}
81
82/// Expand a leading `~` in an operator-supplied path against `$HOME`.
83///
84/// `~` alone and `~/rest` expand (#152: `AION_HOME=~/.aion` and
85/// `--config ~/aion.toml` reach the loader unexpanded whenever a shell did not
86/// get to them — a quoted value, an env file, a process manager). Any other
87/// `~`-leading form (`~user/...`) is a loud typed refusal: expanding it needs
88/// the user database, and silently treating it as a relative directory
89/// literally named `~user` is exactly the quiet misdirection this fixes. Every
90/// other path passes through untouched.
91pub(super) fn expand_tilde(path: &Path) -> Result<PathBuf, ServerError> {
92    expand_tilde_against(path, std::env::var_os("HOME"))
93}
94
95fn expand_tilde_against(path: &Path, home: Option<OsString>) -> Result<PathBuf, ServerError> {
96    let mut components = path.components();
97    let Some(Component::Normal(first)) = components.next() else {
98        return Ok(path.to_owned());
99    };
100    if first == "~" {
101        let Some(home) = home.filter(|value| !value.is_empty()) else {
102            return Err(ServerError::Config {
103                message: format!(
104                    "cannot expand `~` in path `{}`: HOME is not set",
105                    path.display()
106                ),
107            });
108        };
109        let rest = components.as_path();
110        return Ok(if rest.as_os_str().is_empty() {
111            PathBuf::from(home)
112        } else {
113            PathBuf::from(home).join(rest)
114        });
115    }
116    if first.as_encoded_bytes().starts_with(b"~") {
117        return Err(ServerError::Config {
118            message: format!(
119                "cannot resolve `{}`: `~user` expansion is not supported; use an absolute \
120                 path (a directory literally named `{}` can be spelled `./{}`)",
121                path.display(),
122                Path::new(first).display(),
123                Path::new(first).display()
124            ),
125        });
126    }
127    Ok(path.to_owned())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn home() -> OsString {
135        OsString::from("/users/probe")
136    }
137
138    #[test]
139    fn a_bare_tilde_expands_to_home() -> Result<(), Box<dyn std::error::Error>> {
140        assert_eq!(
141            expand_tilde_against(Path::new("~"), Some(home()))?,
142            PathBuf::from("/users/probe")
143        );
144        Ok(())
145    }
146
147    #[test]
148    fn a_tilde_slash_prefix_expands_under_home() -> Result<(), Box<dyn std::error::Error>> {
149        assert_eq!(
150            expand_tilde_against(Path::new("~/.aion/config.toml"), Some(home()))?,
151            PathBuf::from("/users/probe/.aion/config.toml")
152        );
153        Ok(())
154    }
155
156    #[test]
157    fn non_tilde_paths_pass_through_untouched() -> Result<(), Box<dyn std::error::Error>> {
158        for literal in ["/absolute/path", "relative/path", "./~escaped", ""] {
159            assert_eq!(
160                expand_tilde_against(Path::new(literal), Some(home()))?,
161                PathBuf::from(literal),
162                "`{literal}` must not be rewritten"
163            );
164        }
165        Ok(())
166    }
167
168    /// A path whose SECOND component is a tilde is a literal name, not an
169    /// expansion site — only a leading tilde means "my home".
170    #[test]
171    fn an_interior_tilde_is_a_literal_name() -> Result<(), Box<dyn std::error::Error>> {
172        assert_eq!(
173            expand_tilde_against(Path::new("/srv/~backup"), Some(home()))?,
174            PathBuf::from("/srv/~backup")
175        );
176        Ok(())
177    }
178
179    #[test]
180    fn a_tilde_user_form_is_a_loud_typed_refusal() -> Result<(), Box<dyn std::error::Error>> {
181        let error = expand_tilde_against(Path::new("~alice/.aion"), Some(home()))
182            .err()
183            .ok_or("`~alice` was accepted")?;
184        let message = error.to_string();
185        assert!(message.contains("~user"));
186        assert!(message.contains("~alice"));
187        assert!(message.contains("./~alice"));
188        Ok(())
189    }
190
191    #[test]
192    fn a_tilde_without_home_is_a_typed_error() -> Result<(), Box<dyn std::error::Error>> {
193        for absent in [None, Some(OsString::new())] {
194            let error = expand_tilde_against(Path::new("~/.aion"), absent)
195                .err()
196                .ok_or("`~` expanded without HOME")?;
197            assert!(error.to_string().contains("HOME is not set"));
198        }
199        Ok(())
200    }
201}