Skip to main content

aion_server/config/
file.rs

1//! TOML config file discovery, reading, and parsing.
2
3use std::{fs, path::Path};
4
5use crate::{config::ServerConfig, error::ServerError};
6
7use super::ConfigSource;
8
9const PROJECT_CONFIG_FILE: &str = "aion.toml";
10/// The user-level config file name under the Aion home; shared with the
11/// boot-side first-run scaffold so discovery and the scaffold cannot drift.
12pub(super) const HOME_CONFIG_FILE: &str = "config.toml";
13
14/// Bytes and provenance from the winning file-discovery layer.
15pub(super) struct DiscoveredFile {
16    pub(super) bytes: Option<Vec<u8>>,
17    pub(super) source: ConfigSource,
18}
19
20/// Discover and read server config in the exact order: explicit `--config`,
21/// project-local `./aion.toml`, `<AION_HOME>/config.toml`, then built-in defaults.
22///
23/// Only the first discovered file is read. A missing explicit path and every
24/// read error are loud typed failures; a discovered file is never silently
25/// skipped in favor of a lower-precedence layer.
26///
27/// # Errors
28///
29/// Returns [`ServerError::Config`] when an explicit path is missing or the
30/// winning file cannot be read. Parsing and validation happen in the merged
31/// loader and are likewise loud typed startup failures.
32pub(super) fn discover(
33    explicit: Option<&Path>,
34    home: &Path,
35    working_dir: &Path,
36) -> Result<DiscoveredFile, ServerError> {
37    match discover_path(explicit, home, working_dir)? {
38        Some((path, source)) => read(&path, source),
39        None => Ok(DiscoveredFile {
40            bytes: None,
41            source: ConfigSource::BuiltInDefaults,
42        }),
43    }
44}
45
46/// The winning file-discovery layer's path and provenance, without reading it
47/// — the single discovery order stated once, shared by [`discover`] and the
48/// boot-side config heal (which must edit exactly the file the load that
49/// follows it will read). `None` is the built-in-defaults layer: no file.
50///
51/// An explicit `--config` path is returned without an existence check, exactly
52/// as [`discover`] treats it: a missing explicit file is a loud refusal at
53/// read time, never a silent fall-through to a lower layer.
54///
55/// # Errors
56///
57/// Returns [`ServerError::Config`] when a discovery layer's presence cannot be
58/// determined (the same metadata failures [`discover`] refuses on).
59pub(super) fn discover_path(
60    explicit: Option<&Path>,
61    home: &Path,
62    working_dir: &Path,
63) -> Result<Option<(std::path::PathBuf, ConfigSource)>, ServerError> {
64    if let Some(path) = explicit {
65        return Ok(Some((
66            path.to_owned(),
67            ConfigSource::Explicit(path.to_owned()),
68        )));
69    }
70    let project = working_dir.join(PROJECT_CONFIG_FILE);
71    if is_present(&project)? {
72        let source = ConfigSource::ProjectLocal(project.clone());
73        return Ok(Some((project, source)));
74    }
75    let user = home.join(HOME_CONFIG_FILE);
76    if is_present(&user)? {
77        let source = ConfigSource::AionHome(user.clone());
78        return Ok(Some((user, source)));
79    }
80    Ok(None)
81}
82
83fn is_present(path: &Path) -> Result<bool, ServerError> {
84    match fs::symlink_metadata(path) {
85        Ok(_) => Ok(true),
86        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
87        Err(error) => Err(ServerError::Config {
88            message: format!(
89                "failed to inspect config path `{}`: {error}",
90                path.display()
91            ),
92        }),
93    }
94}
95
96fn read(path: &Path, source: ConfigSource) -> Result<DiscoveredFile, ServerError> {
97    let bytes = fs::read(path).map_err(|error| ServerError::Config {
98        message: format!("failed to read config `{}`: {error}", path.display()),
99    })?;
100    Ok(DiscoveredFile {
101        bytes: Some(bytes),
102        source,
103    })
104}
105
106/// Load and validate a required TOML config file.
107///
108/// # Errors
109///
110/// Returns [`ServerError::Config`] when the file is missing, unreadable,
111/// unparsable, invalid, or Aion home cannot be resolved for omitted path
112/// defaults.
113pub fn load_required(path: &Path) -> Result<ServerConfig, ServerError> {
114    let bytes = fs::read(path).map_err(|error| ServerError::Config {
115        message: format!("failed to read config `{}`: {error}", path.display()),
116    })?;
117    ServerConfig::from_slice(&bytes).map_err(|error| ServerError::Config {
118        message: format!("failed to load config `{}`: {error}", path.display()),
119    })
120}
121
122#[cfg(all(test, unix))]
123mod tests {
124    use std::os::unix::fs::{PermissionsExt, symlink};
125
126    use super::*;
127
128    #[test]
129    fn dangling_project_config_is_loud_and_home_is_not_read()
130    -> Result<(), Box<dyn std::error::Error>> {
131        let sandbox = crate::test_support::private_tempdir()?;
132        let working = sandbox.path().join("working");
133        let home = sandbox.path().join("home");
134        fs::create_dir(&working)?;
135        fs::create_dir(&home)?;
136        fs::write(home.join(HOME_CONFIG_FILE), b"not valid toml =")?;
137        symlink(
138            sandbox.path().join("missing"),
139            working.join(PROJECT_CONFIG_FILE),
140        )?;
141
142        let error = discover(None, &home, &working)
143            .err()
144            .ok_or("expected failure")?;
145        assert!(error.to_string().contains(PROJECT_CONFIG_FILE));
146        assert!(!error.to_string().contains("parse"));
147        Ok(())
148    }
149
150    #[test]
151    fn dangling_home_config_is_loud_instead_of_defaults() -> Result<(), Box<dyn std::error::Error>>
152    {
153        let sandbox = crate::test_support::private_tempdir()?;
154        let working = sandbox.path().join("working");
155        let home = sandbox.path().join("home");
156        fs::create_dir(&working)?;
157        fs::create_dir(&home)?;
158        symlink(sandbox.path().join("missing"), home.join(HOME_CONFIG_FILE))?;
159
160        let error = discover(None, &home, &working)
161            .err()
162            .ok_or("expected failure")?;
163        assert!(error.to_string().contains(HOME_CONFIG_FILE));
164        Ok(())
165    }
166
167    #[test]
168    fn unstatable_project_and_home_paths_are_loud() -> Result<(), Box<dyn std::error::Error>> {
169        let sandbox = crate::test_support::private_tempdir()?;
170        let working = sandbox.path().join("working");
171        let home = sandbox.path().join("home");
172        fs::create_dir(&working)?;
173        fs::create_dir(&home)?;
174        fs::write(home.join(HOME_CONFIG_FILE), b"lower layer must not win")?;
175
176        fs::set_permissions(&working, fs::Permissions::from_mode(0o000))?;
177        let project_result = discover(None, &home, &working);
178        fs::set_permissions(&working, fs::Permissions::from_mode(0o700))?;
179        assert!(
180            project_result.is_err(),
181            "project metadata failure was suppressed"
182        );
183
184        fs::set_permissions(&home, fs::Permissions::from_mode(0o000))?;
185        let home_result = discover(None, &home, &working);
186        fs::set_permissions(&home, fs::Permissions::from_mode(0o700))?;
187        assert!(home_result.is_err(), "home metadata failure was suppressed");
188        Ok(())
189    }
190}