soma_gateway/gateway/
config_store.rs1use 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;