Skip to main content

git_ark/
config.rs

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    /// Whether this host is the client-enforced singleton GitHub mirror.
13    /// Only a host with `mirror = true` runs the GitHub mirror step on
14    /// backup — the token itself lives in `secrets.toml` `[github]`, which is
15    /// present only here too. Set by `hostspec::render_config`, never by
16    /// hand.
17    #[serde(default)]
18    pub mirror: bool,
19    /// Refs whose push triggers the durable S3 backup. A push that updates a
20    /// matching ref is bundled/encrypted/uploaded; other pushes stay on the
21    /// host only. A bare name (`main`) matches `refs/heads/main`; `**` matches
22    /// every ref. Set to `["**"]` to back up every push.
23    #[serde(default = "default_backup_refs")]
24    pub backup_refs: Vec<String>,
25    /// Warn on the push summary when the repos filesystem is low. "Low" means
26    /// free% below `disk_warn_percent` AND free bytes below
27    /// `disk_warn_min_free_bytes` — see docs/DESIGN.md. Informational only.
28    #[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 // 10 GiB
46}
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    /// Optional S3-compatible endpoint (MinIO, R2, B2, Wasabi, localstack). When
55    /// set, requests use path-style addressing. Omitted → real AWS S3.
56    #[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    /// Optional STS/SSO session token (`AWS_SESSION_TOKEN`). Only used by
84    /// `restore` on a trusted machine; the write-only host uses static keys.
85    #[serde(default)]
86    pub session_token: Option<String>,
87}
88
89#[derive(Clone, Default, Deserialize)]
90pub struct GithubSecrets {
91    /// Optional default token, used when no per-owner token matches.
92    #[serde(default)]
93    pub token: String,
94    /// Per-owner tokens: GitHub org/user name → token. A fine-grained PAT is
95    /// scoped to a single owner, so mirroring to multiple orgs needs one token
96    /// each. Matched against a repo's `.git-ark.yml` `github.owner`.
97    #[serde(default)]
98    pub tokens: std::collections::HashMap<String, String>,
99}
100
101impl GithubSecrets {
102    /// Token to use when mirroring to `owner`: the per-owner token (matched
103    /// case-insensitively, since GitHub names are) if present, else the default
104    /// `token` if set, else `None`.
105    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"); // default applied
231        assert_eq!(c.repos_root, std::path::PathBuf::from("/srv/repos"));
232        // backup_refs defaults to main/master when omitted.
233        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        // Per-owner match is case-insensitive.
393        assert_eq!(gh.token_for("acme"), Some("owner-token"));
394        // No per-owner match → the default token.
395        assert_eq!(gh.token_for("other-user"), Some("default-token"));
396        // Neither a per-owner match nor a default → None.
397        assert_eq!(GithubSecrets::default().token_for("anyone"), None);
398    }
399}