Skip to main content

flares_client/
config.rs

1//! Explicit YAML loading and discovery shared by the SDK and CLI.
2use anyhow::{Result, bail};
3use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
4use std::{
5    fmt, fs,
6    path::{Path, PathBuf},
7};
8
9/// Search XDG_CONFIG_HOME (when absolute), otherwise HOME/.config, then /etc/flares.
10/// Files are selected without merging; an existing but invalid file never falls back.
11pub 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        // Inspect the directory entry so a broken symlink is an error, not a fallback.
41        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            // An invalid user file must be selected, not silently bypassed.
129            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        // Never echo the parser's message: it may quote a credential or a full YAML value.
259        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}