Skip to main content

infrastore_server/
config.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct ServerConfig {
8    pub server: ServerSection,
9    pub data: DataSection,
10    #[serde(default)]
11    pub authentication: AuthSection,
12}
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct ServerSection {
16    pub host: String,
17    pub port: u16,
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct DataSection {
22    /// Paths to NetCDF files served read-only by this server. v0 supports a
23    /// single file (the first entry); multi-file is reserved for a follow-up.
24    pub files: Vec<PathBuf>,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct AuthSection {
29    /// "none" | "api_key". `oauth` is reserved for a later milestone.
30    #[serde(default = "default_auth_method")]
31    pub method: String,
32
33    /// API keys accepted when `method = "api_key"`. Checked against the
34    /// request's `x-api-key` header without early-exit across keys; see
35    /// `auth::any_match` for the exact timing guarantee.
36    #[serde(default)]
37    pub keys: Vec<String>,
38}
39
40/// Must agree with `default_auth_method`: `#[serde(default)]` on
41/// `ServerConfig::authentication` builds the section from `Default`, so an
42/// omitted `[authentication]` table has to land on a *valid* method.
43impl Default for AuthSection {
44    fn default() -> Self {
45        Self {
46            method: default_auth_method(),
47            keys: Vec::new(),
48        }
49    }
50}
51
52fn default_auth_method() -> String {
53    "none".into()
54}
55
56impl AuthSection {
57    /// Returns Err(...) on a config-time problem (e.g. method requires keys
58    /// but none provided). Called by the server on startup so misconfiguration
59    /// fails loudly rather than at the first request.
60    pub fn validate(&self) -> Result<(), String> {
61        match self.method.as_str() {
62            "none" => Ok(()),
63            "api_key" => {
64                if self.keys.is_empty() {
65                    Err(
66                        "authentication.method = \"api_key\" requires at least one entry in keys"
67                            .into(),
68                    )
69                } else {
70                    Ok(())
71                }
72            }
73            other => Err(format!("unsupported authentication.method: {other}")),
74        }
75    }
76}
77
78impl ServerConfig {
79    pub fn load(path: &Path) -> Result<Self, ConfigError> {
80        let s = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
81        toml::from_str(&s).map_err(ConfigError::Parse)
82    }
83}
84
85#[derive(Debug, thiserror::Error)]
86pub enum ConfigError {
87    #[error("io error: {0}")]
88    Io(#[from] std::io::Error),
89    #[error("parse error: {0}")]
90    Parse(#[from] toml::de::Error),
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    const BASE: &str = r#"
98[server]
99host = "127.0.0.1"
100port = 50051
101
102[data]
103files = ["store.nc"]
104"#;
105
106    #[test]
107    fn omitted_authentication_section_defaults_to_none() {
108        let cfg: ServerConfig = toml::from_str(BASE).unwrap();
109        assert_eq!(cfg.authentication.method, "none");
110        cfg.authentication.validate().unwrap();
111    }
112
113    #[test]
114    fn authentication_section_without_method_defaults_to_none() {
115        let cfg: ServerConfig = toml::from_str(&format!("{BASE}\n[authentication]\n")).unwrap();
116        assert_eq!(cfg.authentication.method, "none");
117        cfg.authentication.validate().unwrap();
118    }
119
120    #[test]
121    fn api_key_without_keys_is_rejected() {
122        let cfg: ServerConfig =
123            toml::from_str(&format!("{BASE}\n[authentication]\nmethod = \"api_key\"\n")).unwrap();
124        assert!(cfg.authentication.validate().is_err());
125    }
126}