Skip to main content

soma_gateway/gateway/
config_store.rs

1use std::fs;
2use std::io::Write;
3use std::path::Path;
4
5use crate::config::{ConfigError, GatewayConfig, GatewayPaths};
6
7#[derive(Debug, Clone)]
8pub struct FsGatewayConfigStore {
9    paths: GatewayPaths,
10}
11
12impl FsGatewayConfigStore {
13    pub fn new(home: std::path::PathBuf) -> Self {
14        let paths = GatewayPaths::new(home).expect("valid gateway home");
15        Self { paths }
16    }
17
18    pub fn from_paths(paths: GatewayPaths) -> Self {
19        Self { paths }
20    }
21
22    #[must_use]
23    pub fn paths(&self) -> &GatewayPaths {
24        &self.paths
25    }
26
27    pub fn install_default(&self) -> Result<GatewayConfig, ConfigError> {
28        let cfg = GatewayConfig::default();
29        self.save(&cfg)?;
30        Ok(cfg)
31    }
32
33    pub fn load_or_install_default(&self) -> Result<GatewayConfig, ConfigError> {
34        if self.paths.config_path().exists() {
35            self.load()
36        } else {
37            self.install_default()
38        }
39    }
40
41    pub fn load(&self) -> Result<GatewayConfig, ConfigError> {
42        let config_path = self.paths.config_path();
43        let raw =
44            fs::read_to_string(&config_path).map_err(|err| ConfigError::io(&config_path, err))?;
45        let cfg: GatewayConfig = toml::from_str(&raw)?;
46        cfg.validate()?;
47        Ok(cfg)
48    }
49
50    pub fn save(&self, cfg: &GatewayConfig) -> Result<(), ConfigError> {
51        cfg.validate()?;
52        let config_path = self.paths.config_path();
53        write_file_atomically(&config_path, &toml::to_string_pretty(cfg)?, false)
54    }
55
56    pub fn write_env_secret(&self, key: &str, value: &str) -> Result<(), ConfigError> {
57        crate::config::upstream::validate_bearer_token_env(key)?;
58        let env_path = self.paths.env_path();
59        let mut entries = parse_env_file(&env_path)?;
60        entries.retain(|(existing, _)| existing != key);
61        entries.push((key.to_owned(), value.to_owned()));
62        let rendered = entries
63            .into_iter()
64            .map(|(key, value)| format!("{key}={}\n", quote_env_value(&value)))
65            .collect::<String>();
66        write_file_atomically(&env_path, &rendered, true)
67    }
68}
69
70fn write_file_atomically(path: &Path, body: &str, secret: bool) -> Result<(), ConfigError> {
71    let parent = path.parent().unwrap_or_else(|| Path::new("."));
72    fs::create_dir_all(parent).map_err(|err| ConfigError::io(parent, err))?;
73    reject_symlink(path)?;
74
75    let tmp = parent.join(format!(
76        ".{}.tmp.{}",
77        path.file_name()
78            .and_then(|name| name.to_str())
79            .unwrap_or("file"),
80        std::process::id()
81    ));
82    {
83        let mut file = fs::File::create(&tmp).map_err(|err| ConfigError::io(&tmp, err))?;
84        file.write_all(body.as_bytes())
85            .map_err(|err| ConfigError::io(&tmp, err))?;
86        file.sync_all().map_err(|err| ConfigError::io(&tmp, err))?;
87    }
88    if secret {
89        restrict_secret_file_permissions(&tmp)?;
90    }
91    fs::rename(&tmp, path).map_err(|err| ConfigError::io(path, err))?;
92    if secret {
93        restrict_secret_file_permissions(path)?;
94    }
95    Ok(())
96}
97
98fn parse_env_file(path: &Path) -> Result<Vec<(String, String)>, ConfigError> {
99    let raw = match fs::read_to_string(path) {
100        Ok(raw) => raw,
101        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
102        Err(err) => return Err(ConfigError::io(path, err)),
103    };
104    Ok(raw
105        .lines()
106        .filter_map(|line| {
107            let trimmed = line.trim();
108            if trimmed.is_empty() || trimmed.starts_with('#') {
109                return None;
110            }
111            trimmed
112                .split_once('=')
113                .map(|(key, value)| (key.trim().to_owned(), unquote_env_value(value.trim())))
114        })
115        .collect())
116}
117
118fn reject_symlink(path: &Path) -> Result<(), ConfigError> {
119    match fs::symlink_metadata(path) {
120        Ok(metadata) if metadata.file_type().is_symlink() => {
121            Err(ConfigError::invalid("path", "must not be a symlink"))
122        }
123        Ok(_) => Ok(()),
124        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
125        Err(err) => Err(ConfigError::io(path, err)),
126    }
127}
128
129fn quote_env_value(value: &str) -> String {
130    let needs_quotes = value
131        .chars()
132        .any(|ch| matches!(ch, ' ' | '\t' | '#' | '$' | '\\' | '"' | '\'' | '`'));
133    if needs_quotes {
134        format!("\"{}\"", value.replace('\\', r"\\").replace('"', r#"\""#))
135    } else {
136        value.to_owned()
137    }
138}
139
140fn unquote_env_value(value: &str) -> String {
141    value
142        .strip_prefix('"')
143        .and_then(|inner| inner.strip_suffix('"'))
144        .map_or_else(
145            || value.to_owned(),
146            |inner| inner.replace(r#"\""#, "\"").replace(r"\\", r"\"),
147        )
148}
149
150fn restrict_secret_file_permissions(path: &Path) -> Result<(), ConfigError> {
151    #[cfg(unix)]
152    {
153        use std::os::unix::fs::PermissionsExt;
154        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
155            .map_err(|err| ConfigError::io(path, err))?;
156    }
157    #[cfg(not(unix))]
158    let _ = path;
159    Ok(())
160}
161
162#[cfg(test)]
163#[path = "config_store_tests.rs"]
164mod tests;