1use std::io::Write;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::alias::Alias;
14use crate::error::{Error, Result};
15
16pub const SCHEMA_VERSION: u32 = 1;
23
24const DEFAULT_OUTPUT: &str = "human";
26
27const DEFAULT_COLOR: &str = "auto";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Config {
33 pub schema_version: u32,
35
36 #[serde(default)]
38 pub defaults: Defaults,
39
40 #[serde(default)]
42 pub aliases: Vec<Alias>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Defaults {
48 #[serde(default = "default_output")]
50 pub output: String,
51
52 #[serde(default = "default_color")]
54 pub color: String,
55
56 #[serde(default = "default_true")]
58 pub progress: bool,
59}
60
61fn default_output() -> String {
62 DEFAULT_OUTPUT.to_string()
63}
64
65fn default_color() -> String {
66 DEFAULT_COLOR.to_string()
67}
68
69fn default_true() -> bool {
70 true
71}
72
73impl Default for Defaults {
74 fn default() -> Self {
75 Self {
76 output: default_output(),
77 color: default_color(),
78 progress: true,
79 }
80 }
81}
82
83impl Default for Config {
84 fn default() -> Self {
85 Self {
86 schema_version: SCHEMA_VERSION,
87 defaults: Defaults::default(),
88 aliases: Vec::new(),
89 }
90 }
91}
92
93#[derive(Debug)]
95pub struct ConfigManager {
96 config_path: PathBuf,
97}
98
99impl ConfigManager {
100 pub fn new() -> Result<Self> {
105 let config_dir = if let Ok(dir) = std::env::var("RC_CONFIG_DIR") {
106 PathBuf::from(dir)
107 } else {
108 dirs::config_dir()
109 .ok_or_else(|| Error::Config("Could not determine config directory".into()))?
110 .join("rc")
111 };
112 let config_path = config_dir.join("config.toml");
113 Ok(Self { config_path })
114 }
115
116 pub fn with_path(path: PathBuf) -> Self {
118 Self { config_path: path }
119 }
120
121 pub fn config_path(&self) -> &PathBuf {
123 &self.config_path
124 }
125
126 pub fn load(&self) -> Result<Config> {
131 if !self.config_path.exists() {
132 return Ok(Config::default());
133 }
134
135 let content = std::fs::read_to_string(&self.config_path)?;
136 let mut config: Config = toml::from_str(&content)?;
137
138 if config.schema_version < SCHEMA_VERSION {
140 config = self.migrate(config)?;
141 } else if config.schema_version > SCHEMA_VERSION {
142 return Err(Error::Config(format!(
143 "Configuration file version {} is newer than supported version {}. Please upgrade rc.",
144 config.schema_version, SCHEMA_VERSION
145 )));
146 }
147
148 Ok(config)
149 }
150
151 pub fn save(&self, config: &Config) -> Result<()> {
156 if let Some(parent) = self.config_path.parent() {
158 std::fs::create_dir_all(parent)?;
159 }
160
161 let content = toml::to_string_pretty(config)?;
162 let mut options = std::fs::OpenOptions::new();
163 options.write(true).create(true).truncate(false);
164
165 #[cfg(unix)]
166 {
167 use std::os::unix::fs::OpenOptionsExt;
168 options.mode(0o600);
169 }
170
171 let mut file = options.open(&self.config_path)?;
172
173 #[cfg(unix)]
176 {
177 use std::os::unix::fs::PermissionsExt;
178 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
179 }
180
181 file.set_len(0)?;
182 file.write_all(content.as_bytes())?;
183
184 Ok(())
185 }
186
187 fn migrate(&self, config: Config) -> Result<Config> {
189 let mut config = config;
190
191 config.schema_version = SCHEMA_VERSION;
198 Ok(config)
199 }
200}
201
202impl Default for ConfigManager {
203 fn default() -> Self {
204 Self::new().expect("Failed to create default ConfigManager")
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use tempfile::TempDir;
212
213 fn temp_config_manager() -> (ConfigManager, TempDir) {
214 let temp_dir = TempDir::new().unwrap();
215 let config_path = temp_dir.path().join("config.toml");
216 let manager = ConfigManager::with_path(config_path);
217 (manager, temp_dir)
218 }
219
220 #[test]
221 fn test_default_config() {
222 let config = Config::default();
223 assert_eq!(config.schema_version, SCHEMA_VERSION);
224 assert_eq!(config.defaults.output, "human");
225 assert_eq!(config.defaults.color, "auto");
226 assert!(config.defaults.progress);
227 assert!(config.aliases.is_empty());
228 }
229
230 #[test]
231 fn test_load_nonexistent_returns_default() {
232 let (manager, _temp_dir) = temp_config_manager();
233 let config = manager.load().unwrap();
234 assert_eq!(config.schema_version, SCHEMA_VERSION);
235 }
236
237 #[test]
238 fn test_save_and_load() {
239 let (manager, _temp_dir) = temp_config_manager();
240
241 let mut config = Config::default();
242 config.aliases.push(Alias {
243 name: "test".to_string(),
244 endpoint: "http://localhost:9000".to_string(),
245 access_key: "accesskey".to_string(),
246 secret_key: "secretkey".to_string(),
247 anonymous: false,
248 client_cert: None,
249 client_key: None,
250 region: "us-east-1".to_string(),
251 signature: "v4".to_string(),
252 bucket_lookup: "auto".to_string(),
253 insecure: false,
254 ca_bundle: None,
255 retry: None,
256 timeout: None,
257 });
258
259 manager.save(&config).unwrap();
260 let loaded = manager.load().unwrap();
261
262 #[cfg(unix)]
263 {
264 use std::os::unix::fs::PermissionsExt;
265 let mode = std::fs::metadata(manager.config_path())
266 .unwrap()
267 .permissions()
268 .mode();
269 assert_eq!(mode & 0o777, 0o600);
270 }
271
272 assert_eq!(loaded.aliases.len(), 1);
273 assert_eq!(loaded.aliases[0].name, "test");
274 }
275
276 #[test]
277 fn test_save_replaces_longer_config() {
278 let (manager, _temp_dir) = temp_config_manager();
279 let mut config = Config::default();
280 config.defaults.output = "a".repeat(1024);
281 manager.save(&config).unwrap();
282
283 let config = Config::default();
284 manager.save(&config).unwrap();
285
286 assert_eq!(
287 std::fs::read_to_string(manager.config_path()).unwrap(),
288 toml::to_string_pretty(&config).unwrap()
289 );
290 }
291
292 #[cfg(unix)]
293 #[test]
294 fn test_save_permissions_before_write() {
295 use std::os::unix::fs::PermissionsExt;
296 use std::process::Command;
297
298 const CHILD_CONFIG_PATH: &str = "RC_TEST_SAVE_PERMISSIONS_PATH";
299 if let Some(path) = std::env::var_os(CHILD_CONFIG_PATH) {
300 let manager = ConfigManager::with_path(PathBuf::from(path));
301 manager.save(&Config::default()).unwrap();
302 return;
303 }
304
305 for existing in [false, true] {
306 let (manager, _temp_dir) = temp_config_manager();
307 if existing {
308 std::fs::write(manager.config_path(), "old config").unwrap();
309 std::fs::set_permissions(
310 manager.config_path(),
311 std::fs::Permissions::from_mode(0o666),
312 )
313 .unwrap();
314 }
315
316 let output = Command::new("sh")
319 .args([
320 "-c",
321 "umask 000 && ulimit -c 0 && ulimit -f 0 && exec \"$@\"",
322 "sh",
323 ])
324 .arg(std::env::current_exe().unwrap())
325 .args([
326 "--exact",
327 "config::tests::test_save_permissions_before_write",
328 ])
329 .env(CHILD_CONFIG_PATH, manager.config_path())
330 .output()
331 .unwrap();
332
333 assert!(
334 !output.status.success(),
335 "the file-size limit must reject writes"
336 );
337 let metadata = std::fs::metadata(manager.config_path()).unwrap();
338 assert_eq!(metadata.len(), 0);
339 assert_eq!(
340 metadata.permissions().mode() & 0o777,
341 0o600,
342 "config must already be private when writing fails (existing: {existing})"
343 );
344 }
345 }
346
347 #[test]
348 fn test_schema_version_too_new() {
349 let (manager, _temp_dir) = temp_config_manager();
350
351 let content = format!(
352 r#"
353 schema_version = {}
354 "#,
355 SCHEMA_VERSION + 1
356 );
357 std::fs::write(manager.config_path(), content).unwrap();
358
359 let result = manager.load();
360 assert!(result.is_err());
361 assert!(
362 result
363 .unwrap_err()
364 .to_string()
365 .contains("newer than supported")
366 );
367 }
368}