1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct Config {
7 pub repos_root: PathBuf,
8 pub age_recipient: String,
9 pub s3: S3Config,
10 #[serde(default)]
11 pub github: GithubConfig,
12 #[serde(default)]
18 pub mirror: bool,
19 #[serde(default = "default_backup_refs")]
24 pub backup_refs: Vec<String>,
25 #[serde(default = "default_disk_warn_percent")]
29 pub disk_warn_percent: u8,
30 #[serde(default = "default_disk_warn_min_free_bytes")]
31 pub disk_warn_min_free_bytes: u64,
32}
33
34fn default_backup_refs() -> Vec<String> {
35 vec![
36 "refs/heads/main".to_string(),
37 "refs/heads/master".to_string(),
38 ]
39}
40
41fn default_disk_warn_percent() -> u8 {
42 15
43}
44fn default_disk_warn_min_free_bytes() -> u64 {
45 10 * 1024 * 1024 * 1024 }
47
48#[derive(Debug, Clone, Deserialize)]
49pub struct S3Config {
50 pub bucket: String,
51 pub region: String,
52 #[serde(default = "default_prefix")]
53 pub prefix: String,
54 #[serde(default)]
57 pub endpoint: Option<String>,
58}
59
60fn default_prefix() -> String {
61 "git-ark".to_string()
62}
63
64#[derive(Debug, Clone, Default, Deserialize)]
65pub struct GithubConfig {
66 #[serde(default)]
67 pub owner: String,
68 #[serde(default)]
69 pub mirror: Vec<String>,
70}
71
72#[derive(Clone, Deserialize)]
73pub struct Secrets {
74 pub aws: AwsSecrets,
75 #[serde(default)]
76 pub github: GithubSecrets,
77}
78
79#[derive(Clone, Deserialize)]
80pub struct AwsSecrets {
81 pub access_key_id: String,
82 pub secret_access_key: String,
83 #[serde(default)]
86 pub session_token: Option<String>,
87}
88
89#[derive(Clone, Default, Deserialize)]
90pub struct GithubSecrets {
91 #[serde(default)]
93 pub token: String,
94 #[serde(default)]
98 pub tokens: std::collections::HashMap<String, String>,
99}
100
101impl GithubSecrets {
102 pub fn token_for(&self, owner: &str) -> Option<&str> {
106 if let Some((_, v)) = self
107 .tokens
108 .iter()
109 .find(|(k, _)| k.eq_ignore_ascii_case(owner))
110 {
111 return Some(v.as_str());
112 }
113 if self.token.is_empty() {
114 None
115 } else {
116 Some(self.token.as_str())
117 }
118 }
119}
120
121impl std::fmt::Debug for AwsSecrets {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("AwsSecrets")
124 .field("access_key_id", &"[redacted]")
125 .field("secret_access_key", &"[redacted]")
126 .finish()
127 }
128}
129
130impl std::fmt::Debug for GithubSecrets {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("GithubSecrets")
133 .field("token", &"[redacted]")
134 .field(
135 "tokens",
136 &format!("[{} owner token(s) redacted]", self.tokens.len()),
137 )
138 .finish()
139 }
140}
141
142impl std::fmt::Debug for Secrets {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("Secrets")
145 .field("aws", &"[redacted]")
146 .field("github", &"[redacted]")
147 .finish()
148 }
149}
150
151impl Config {
152 pub fn load(path: &Path) -> Result<Config> {
153 let text = std::fs::read_to_string(path)
154 .with_context(|| format!("reading config {}", path.display()))?;
155 let cfg: Config = toml::from_str(&text).context("parsing config.toml")?;
156 cfg.validate()?;
157 Ok(cfg)
158 }
159
160 fn validate(&self) -> Result<()> {
161 if !self.age_recipient.starts_with("age1") {
162 bail!("age_recipient must be an age public key starting with 'age1'");
163 }
164 if self.s3.bucket.is_empty() {
165 bail!("s3.bucket is required");
166 }
167 if self.s3.region.is_empty() {
168 bail!("s3.region is required");
169 }
170 Ok(())
171 }
172}
173
174impl Secrets {
175 pub fn load(path: &Path) -> Result<Secrets> {
176 #[cfg(unix)]
177 {
178 use std::os::unix::fs::PermissionsExt;
179 let meta = std::fs::metadata(path)
180 .with_context(|| format!("stat secrets {}", path.display()))?;
181 let mode = meta.permissions().mode() & 0o777;
182 if mode & 0o077 != 0 {
183 bail!(
184 "secrets file {} is too permissive ({:o}); run `chmod 600`",
185 path.display(),
186 mode
187 );
188 }
189 }
190 let text = std::fs::read_to_string(path)
191 .with_context(|| format!("reading secrets {}", path.display()))?;
192 let s: Secrets = toml::from_str(&text).context("parsing secrets.toml")?;
193 Ok(s)
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::io::Write;
201
202 fn write(dir: &std::path::Path, name: &str, body: &str, mode: u32) -> std::path::PathBuf {
203 let p = dir.join(name);
204 let mut f = std::fs::File::create(&p).unwrap();
205 f.write_all(body.as_bytes()).unwrap();
206 #[cfg(unix)]
207 {
208 use std::os::unix::fs::PermissionsExt;
209 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap();
210 }
211 p
212 }
213
214 #[test]
215 fn loads_valid_config() {
216 let d = tempfile::tempdir().unwrap();
217 let p = write(
218 d.path(),
219 "config.toml",
220 r#"
221repos_root = "/srv/repos"
222age_recipient = "age1qqqexamplepublickey"
223[s3]
224bucket = "b"
225region = "us-east-1"
226"#,
227 0o644,
228 );
229 let c = Config::load(&p).unwrap();
230 assert_eq!(c.s3.prefix, "git-ark"); assert_eq!(c.repos_root, std::path::PathBuf::from("/srv/repos"));
232 assert_eq!(
234 c.backup_refs,
235 vec![
236 "refs/heads/main".to_string(),
237 "refs/heads/master".to_string()
238 ]
239 );
240 assert_eq!(c.disk_warn_percent, 15);
241 assert_eq!(c.disk_warn_min_free_bytes, 10 * 1024 * 1024 * 1024);
242 assert_eq!(c.s3.endpoint, None);
243 }
244
245 #[test]
246 fn s3_endpoint_parses_when_present() {
247 let d = tempfile::tempdir().unwrap();
248 let p = write(
249 d.path(),
250 "config.toml",
251 r#"
252repos_root = "/srv/repos"
253age_recipient = "age1qqqexamplepublickey"
254[s3]
255bucket = "b"
256region = "us-east-1"
257endpoint = "http://minio:9000"
258"#,
259 0o644,
260 );
261 let c = Config::load(&p).unwrap();
262 assert_eq!(c.s3.endpoint.as_deref(), Some("http://minio:9000"));
263 }
264
265 #[test]
266 fn rejects_non_age_recipient() {
267 let d = tempfile::tempdir().unwrap();
268 let p = write(
269 d.path(),
270 "config.toml",
271 r#"
272repos_root = "/srv/repos"
273age_recipient = "not-a-key"
274[s3]
275bucket = "b"
276region = "us-east-1"
277"#,
278 0o644,
279 );
280 assert!(Config::load(&p).is_err());
281 }
282
283 #[cfg(unix)]
284 #[test]
285 fn rejects_world_readable_secrets() {
286 let d = tempfile::tempdir().unwrap();
287 let p = write(
288 d.path(),
289 "secrets.toml",
290 r#"
291[aws]
292access_key_id = "AKIA"
293secret_access_key = "s"
294"#,
295 0o644,
296 );
297 assert!(Secrets::load(&p).is_err(), "0644 secrets must be rejected");
298 }
299
300 #[cfg(unix)]
301 #[test]
302 fn loads_locked_secrets() {
303 let d = tempfile::tempdir().unwrap();
304 let p = write(
305 d.path(),
306 "secrets.toml",
307 r#"
308[aws]
309access_key_id = "AKIA"
310secret_access_key = "s"
311"#,
312 0o600,
313 );
314 let s = Secrets::load(&p).unwrap();
315 assert_eq!(s.aws.access_key_id, "AKIA");
316 }
317
318 #[test]
319 fn secrets_debug_redacts_sensitive_fields() {
320 let s = Secrets {
321 aws: AwsSecrets {
322 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
323 secret_access_key: "SUPERSECRET123".to_string(),
324 session_token: None,
325 },
326 github: GithubSecrets {
327 token: "ghp_TOKENXYZ123456789".to_string(),
328 tokens: Default::default(),
329 },
330 };
331
332 let debug_output = format!("{:?}", s);
333 assert!(
334 !debug_output.contains("SUPERSECRET123"),
335 "secret_access_key leaked in debug output"
336 );
337 assert!(
338 !debug_output.contains("TOKENXYZ123456789"),
339 "github token leaked in debug output"
340 );
341 assert!(
342 !debug_output.contains("AKIAIOSFODNN7EXAMPLE"),
343 "access_key_id leaked in debug output"
344 );
345 assert!(
346 debug_output.contains("[redacted]"),
347 "debug output should contain redaction marker"
348 );
349 }
350
351 #[test]
352 fn aws_secrets_debug_redacts() {
353 let aws = AwsSecrets {
354 access_key_id: "AKIA1234567890".to_string(),
355 secret_access_key: "secret_abc123xyz".to_string(),
356 session_token: None,
357 };
358 let debug_output = format!("{:?}", aws);
359 assert!(!debug_output.contains("AKIA1234567890"));
360 assert!(!debug_output.contains("secret_abc123xyz"));
361 assert!(debug_output.contains("[redacted]"));
362 }
363
364 #[test]
365 fn github_secrets_debug_redacts() {
366 let mut tokens = std::collections::HashMap::new();
367 tokens.insert("acme".to_string(), "ghp_per_owner_secret".to_string());
368 let gh = GithubSecrets {
369 token: "ghp_sensitive_token_value".to_string(),
370 tokens,
371 };
372 let debug_output = format!("{:?}", gh);
373 assert!(
374 !debug_output.contains("ghp_sensitive_token_value"),
375 "default token leaked"
376 );
377 assert!(
378 !debug_output.contains("ghp_per_owner_secret"),
379 "per-owner token leaked"
380 );
381 assert!(debug_output.contains("[redacted]"));
382 }
383
384 #[test]
385 fn token_for_prefers_per_owner_then_falls_back_to_default() {
386 let mut tokens = std::collections::HashMap::new();
387 tokens.insert("Acme".to_string(), "owner-token".to_string());
388 let gh = GithubSecrets {
389 token: "default-token".to_string(),
390 tokens,
391 };
392 assert_eq!(gh.token_for("acme"), Some("owner-token"));
394 assert_eq!(gh.token_for("other-user"), Some("default-token"));
396 assert_eq!(GithubSecrets::default().token_for("anyone"), None);
398 }
399}