1use anyhow::{Result, bail};
3use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
4use std::{
5 fmt, fs,
6 path::{Path, PathBuf},
7};
8
9pub fn default_path(filename: &str) -> Result<PathBuf> {
12 let home = std::env::var_os("HOME")
13 .filter(|value| !value.is_empty())
14 .map(PathBuf::from);
15 let xdg = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
16 find_config(
17 filename,
18 xdg.as_deref(),
19 home.as_deref(),
20 Path::new("/etc/flares"),
21 )
22}
23
24fn find_config(
25 filename: &str,
26 xdg: Option<&Path>,
27 home: Option<&Path>,
28 system: &Path,
29) -> Result<PathBuf> {
30 let user = xdg
31 .filter(|path| path.is_absolute())
32 .map(Path::to_owned)
33 .or_else(|| home.map(|home| home.join(".config")))
34 .map(|base| base.join("flares").join(filename));
35 let paths: Vec<_> = user
36 .into_iter()
37 .chain(std::iter::once(system.join(filename)))
38 .collect();
39 for path in &paths {
40 match fs::symlink_metadata(path) {
42 Ok(_) => return Ok(path.clone()),
43 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
44 Err(_) => bail!(
45 "Cannot access configuration {}; use --config or an explicit SDK path",
46 path.display()
47 ),
48 }
49 }
50 let searched = paths
51 .iter()
52 .map(|path| path.display().to_string())
53 .collect::<Vec<_>>()
54 .join(", ");
55 bail!("No {filename} found; searched {searched}; use --config or an explicit SDK path")
56}
57
58#[cfg(test)]
59mod lookup_tests {
60 use super::*;
61
62 #[test]
63 fn xdg_replaces_home_and_invalid_xdg_uses_home() {
64 let root = tempfile::tempdir().unwrap();
65 let xdg = root.path().join("xdg");
66 let home = root.path().join("home");
67 let system = root.path().join("etc/flares");
68 for name in ["server.yaml", "client.yaml"] {
69 let user = home.join(".config/flares").join(name);
70 let preferred = xdg.join("flares").join(name);
71 let fallback = system.join(name);
72 for path in [&user, &preferred, &fallback] {
73 fs::create_dir_all(path.parent().unwrap()).unwrap();
74 fs::write(path, "api_token: token\n").unwrap();
75 }
76 assert_eq!(
77 find_config(name, Some(&xdg), Some(&home), &system).unwrap(),
78 preferred
79 );
80 for invalid in [Path::new(""), Path::new("relative")] {
81 assert_eq!(
82 find_config(name, Some(invalid), Some(&home), &system).unwrap(),
83 user
84 );
85 }
86 fs::remove_file(&preferred).unwrap();
87 assert_eq!(
88 find_config(name, Some(&xdg), Some(&home), &system).unwrap(),
89 fallback
90 );
91 }
92 }
93
94 #[cfg(unix)]
95 #[test]
96 fn broken_symlink_is_selected_without_fallback() {
97 let root = tempfile::tempdir().unwrap();
98 let config = root.path().join("flares/client.yaml");
99 fs::create_dir(config.parent().unwrap()).unwrap();
100 std::os::unix::fs::symlink(root.path().join("missing"), &config).unwrap();
101 assert_eq!(
102 find_config(
103 "client.yaml",
104 Some(root.path()),
105 None,
106 &root.path().join("system")
107 )
108 .unwrap(),
109 config
110 );
111 assert!(ClientConfig::load(&config).is_err());
112 }
113
114 #[test]
115 fn user_config_takes_precedence_and_missing_files_fall_back() {
116 let root = tempfile::tempdir().unwrap();
117 let home = root.path().join("home");
118 let user = home.join(".config/flares");
119 let system = root.path().join("etc/flares");
120 fs::create_dir_all(&user).unwrap();
121 fs::create_dir_all(&system).unwrap();
122 for name in ["server.yaml", "client.yaml"] {
123 let path = system.join(name);
124 fs::write(&path, "system").unwrap();
125 assert_eq!(find_config(name, None, Some(&home), &system).unwrap(), path);
126 assert_eq!(find_config(name, None, None, &system).unwrap(), path);
127 let path = user.join(name);
128 fs::write(&path, "invalid YAML [").unwrap();
130 assert_eq!(find_config(name, None, Some(&home), &system).unwrap(), path);
131 assert!(ClientConfig::load(&path).is_err());
132 }
133 }
134
135 #[test]
136 fn missing_config_reports_search_locations_and_does_not_create_directories() {
137 let root = tempfile::tempdir().unwrap();
138 let home = root.path().join("home");
139 let system = root.path().join("etc/flares");
140 let error = find_config("server.yaml", None, Some(&home), &system)
141 .unwrap_err()
142 .to_string();
143 for expected in ["server.yaml", ".config/flares", "etc/flares", "--config"] {
144 assert!(error.contains(expected));
145 }
146 assert!(!home.exists());
147 assert!(!system.exists());
148 }
149}
150
151#[derive(Clone, Deserialize)]
152#[serde(untagged, deny_unknown_fields)]
153pub enum Secret {
154 Literal(String),
155 Environment { env: String },
156 File { file: PathBuf },
157}
158impl Secret {
159 pub fn expose(&self) -> &str {
160 match self {
161 Self::Literal(value) => value,
162 _ => panic!("Secret reference must be resolved by configuration loading"),
163 }
164 }
165 pub fn resolve(&mut self, directory: &Path, field: &str) -> Result<()> {
166 let value = match self {
167 Self::Literal(_) => return Ok(()),
168 Self::Environment { env } => std::env::var(env).map_err(|_| {
169 anyhow::anyhow!("{field}: environment variable is missing or not UTF-8")
170 })?,
171 Self::File { file } => fs::read_to_string(directory.join(file))
172 .map_err(|_| anyhow::anyhow!("{field}: cannot read secret file as UTF-8"))?
173 .trim_end_matches(['\r', '\n'])
174 .to_owned(),
175 };
176 *self = Self::Literal(value);
177 Ok(())
178 }
179}
180impl fmt::Debug for Secret {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 f.write_str("[REDACTED]")
183 }
184}
185impl Serialize for Secret {
186 fn serialize<S: serde::Serializer>(
187 &self,
188 serializer: S,
189 ) -> std::result::Result<S::Ok, S::Error> {
190 serializer.serialize_str("[REDACTED]")
191 }
192}
193
194#[derive(Debug)]
195pub struct ClientConfig {
196 pub base_url: String,
197 pub api_token: Secret,
198 pub timeout: f64,
199}
200
201#[doc(hidden)]
202pub fn directory(path: &Path) -> Result<PathBuf> {
203 Ok(path
204 .canonicalize()
205 .map_err(|_| anyhow::anyhow!("configuration: cannot resolve file directory"))?
206 .parent()
207 .expect("file has parent")
208 .to_owned())
209}
210fn token(secret: &Secret, field: &str) -> Result<()> {
211 if secret.expose().is_empty() || !secret.expose().bytes().all(|b| b.is_ascii_graphic()) {
212 bail!("{field}: must be nonempty printable ASCII without whitespace");
213 }
214 Ok(())
215}
216fn name_valid(name: &str) -> bool {
217 !name.is_empty()
218 && name.len() <= 64
219 && name
220 .bytes()
221 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
222}
223#[derive(Debug, Clone, Copy)]
224#[doc(hidden)]
225pub struct Seconds(pub u64);
226impl<'de> Deserialize<'de> for Seconds {
227 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
228 let text = String::deserialize(deserializer)?;
229 let split = text
230 .find(|c: char| !c.is_ascii_digit())
231 .unwrap_or(text.len());
232 let (number, unit) = text.split_at(split);
233 let multiplier = match unit {
234 "s" => 1,
235 "m" => 60,
236 "h" => 3600,
237 "d" => 86400,
238 "w" => 604800,
239 _ => {
240 return Err(serde::de::Error::custom(
241 "expected duration with s, m, h, d, or w suffix",
242 ));
243 }
244 };
245 number
246 .parse::<u64>()
247 .ok()
248 .and_then(|n| n.checked_mul(multiplier))
249 .map(Self)
250 .ok_or_else(|| serde::de::Error::custom("invalid duration"))
251 }
252}
253#[doc(hidden)]
254pub fn read<T: DeserializeOwned>(path: &Path) -> Result<T> {
255 let text = fs::read_to_string(path)
256 .map_err(|_| anyhow::anyhow!("configuration: cannot read UTF-8 YAML file"))?;
257 serde_path_to_error::deserialize(serde_yaml_ng::Deserializer::from_str(&text)).map_err(|error| {
258 let mut parts=Vec::new();
260 for segment in error.path() {
261 match segment {
262 serde_path_to_error::Segment::Map {key}=> {
263 parts.push(if name_valid(key) {key.clone()} else {"<field>".into()});
264 if matches!(key.as_str(),"api_token"|"app_token"|"user_key"|"bearer_token"|"url") {break;}
265 }
266 serde_path_to_error::Segment::Seq {index}=>parts.push(format!("[{index}]")),
267 _=>{}
268 }
269 }
270 let field=if parts.is_empty() {"configuration".into()} else {parts.join(".")};
271 let location=error.inner().location().map(|l|format!(" at line {}, column {}",l.line(),l.column())).unwrap_or_default();
272 anyhow::anyhow!("{field}{location}: invalid configuration YAML; check field names, required fields, and value types")
273 })
274}
275#[derive(Deserialize)]
276#[serde(deny_unknown_fields)]
277struct Client {
278 base_url: Option<String>,
279 api_token: Secret,
280 timeout: Option<Seconds>,
281}
282fn client(path: &Path) -> Result<ClientConfig> {
283 let raw: Client = read(path)?;
284 Ok(ClientConfig {
285 base_url: raw
286 .base_url
287 .unwrap_or_else(|| "http://127.0.0.1:8000".into()),
288 api_token: raw.api_token,
289 timeout: raw.timeout.map_or(15.0, |value| value.0 as f64),
290 })
291}
292impl ClientConfig {
293 pub fn load(path: &Path) -> Result<Self> {
294 Self::load_inner(path).map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))
295 }
296 fn load_inner(path: &Path) -> Result<Self> {
297 let mut config = client(path)?;
298 config.api_token.resolve(&directory(path)?, "api_token")?;
299 token(&config.api_token, "api_token")?;
300 let url = reqwest::Url::parse(&config.base_url)
301 .map_err(|_| anyhow::anyhow!("base_url: invalid HTTP(S) URL"))?;
302 if !matches!(url.scheme(), "http" | "https")
303 || url.host_str().is_none()
304 || !url.username().is_empty()
305 || url.password().is_some()
306 || url.query().is_some()
307 || url.fragment().is_some()
308 {
309 bail!("base_url: must be HTTP(S) without credentials, query, or fragment");
310 }
311 if !config.timeout.is_finite() || config.timeout <= 0.0 || config.timeout > 86400.0 {
312 bail!("timeout: must be greater than 0s and at most 1d");
313 }
314 Ok(config)
315 }
316 pub fn effective(&self) -> serde_json::Value {
317 serde_json::json!({"base_url":self.base_url,"api_token":self.api_token,"timeout":self.timeout})
318 }
319}