git_crypt/
sync.rs

1use std::path::Path;
2
3#[cfg(not(feature = "sync-s3"))]
4use crate::error::Result;
5
6#[cfg(feature = "sync-s3")]
7mod s3sync {
8    use super::*;
9    use crate::error::{GitCryptError, Result};
10    use config::{Config, File, FileFormat};
11    use s3::{bucket::Bucket, creds::Credentials, region::Region};
12    use serde::Deserialize;
13    use std::fs;
14    use std::path::PathBuf;
15
16    const CONFIG_FILE: &str = ".git-crypt.toml";
17    const ENV_PREFIX: &str = "GIT_CRYPT_SYNC_S3_";
18
19    #[derive(Debug, Deserialize)]
20    #[allow(dead_code)]
21    struct SyncFile {
22        #[serde(default)]
23        sync_s3: Option<SyncS3Config>,
24    }
25
26    #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
27    pub(crate) struct SyncS3Config {
28        #[serde(default = "default_enabled")]
29        pub(crate) enabled: bool,
30        #[serde(default)]
31        pub(crate) bucket: String,
32        #[serde(default)]
33        pub(crate) scope: String,
34        pub(crate) repo: Option<String>,
35        pub(crate) region: Option<String>,
36        pub(crate) endpoint: Option<String>,
37        pub(crate) access_key: Option<String>,
38        pub(crate) secret_key: Option<String>,
39        #[serde(default)]
40        pub(crate) path_style: bool,
41    }
42
43    fn default_enabled() -> bool {
44        true
45    }
46
47    pub fn maybe_sync_age_key(git_dir: &Path, age_file: &Path, alias: &str) -> Result<()> {
48        let repo_root = repo_root_from_git_dir(git_dir);
49        let Some(cfg) = load_config(&repo_root)? else {
50            return Ok(());
51        };
52        if !cfg.enabled {
53            return Ok(());
54        }
55
56        let repo_name = cfg.resolve_repo_name(&repo_root)?;
57        let key_bytes = fs::read(age_file)?;
58        cfg.upload(&repo_name, alias, &key_bytes)?;
59        Ok(())
60    }
61
62    pub(crate) fn load_config(repo_root: &Path) -> Result<Option<SyncS3Config>> {
63        use std::env;
64
65        let config_path = repo_root.join(CONFIG_FILE);
66
67        // Load from file using config crate
68        let mut cfg = if config_path.exists() {
69            let file_cfg = Config::builder()
70                .add_source(File::new(
71                    config_path.to_str().ok_or_else(|| {
72                        GitCryptError::Other("Invalid config path".into())
73                    })?,
74                    FileFormat::Toml,
75                ))
76                .build()
77                .map_err(|err| {
78                    GitCryptError::Other(format!("Failed to load config file: {err}"))
79                })?;
80
81            file_cfg
82                .get::<SyncS3Config>("sync_s3")
83                .ok()
84        } else {
85            None
86        };
87
88        // Apply environment variable overrides manually
89        let env_enabled = env::var(format!("{ENV_PREFIX}ENABLED"))
90            .ok()
91            .and_then(|v| v.parse().ok());
92        let env_bucket = env::var(format!("{ENV_PREFIX}BUCKET")).ok();
93        let env_scope = env::var(format!("{ENV_PREFIX}SCOPE")).ok();
94        let env_repo = env::var(format!("{ENV_PREFIX}REPO")).ok();
95        let env_region = env::var(format!("{ENV_PREFIX}REGION")).ok();
96        let env_endpoint = env::var(format!("{ENV_PREFIX}ENDPOINT")).ok();
97        let env_access_key = env::var(format!("{ENV_PREFIX}ACCESS_KEY")).ok();
98        let env_secret_key = env::var(format!("{ENV_PREFIX}SECRET_KEY")).ok();
99        let env_path_style = env::var(format!("{ENV_PREFIX}PATH_STYLE"))
100            .ok()
101            .and_then(|v| v.parse().ok());
102
103        // If we have a file config, apply env overrides
104        if let Some(ref mut c) = cfg {
105            if let Some(enabled) = env_enabled {
106                c.enabled = enabled;
107            }
108            if let Some(bucket) = env_bucket {
109                c.bucket = bucket;
110            }
111            if let Some(scope) = env_scope {
112                c.scope = scope;
113            }
114            if let Some(repo) = env_repo {
115                c.repo = Some(repo);
116            }
117            if let Some(region) = env_region {
118                c.region = Some(region);
119            }
120            if let Some(endpoint) = env_endpoint {
121                c.endpoint = Some(endpoint);
122            }
123            if let Some(access_key) = env_access_key {
124                c.access_key = Some(access_key);
125            }
126            if let Some(secret_key) = env_secret_key {
127                c.secret_key = Some(secret_key);
128            }
129            if let Some(path_style) = env_path_style {
130                c.path_style = path_style;
131            }
132        } else if env_bucket.is_some() && env_scope.is_some() {
133            // Create config from environment variables only
134            cfg = Some(SyncS3Config {
135                enabled: env_enabled.unwrap_or(true),
136                bucket: env_bucket.unwrap(),
137                scope: env_scope.unwrap(),
138                repo: env_repo,
139                region: env_region,
140                endpoint: env_endpoint,
141                access_key: env_access_key,
142                secret_key: env_secret_key,
143                path_style: env_path_style.unwrap_or(false),
144            });
145        }
146
147        Ok(cfg)
148    }
149
150    fn repo_root_from_git_dir(git_dir: &Path) -> PathBuf {
151        if git_dir.ends_with(".git") {
152            git_dir
153                .parent()
154                .map(Path::to_path_buf)
155                .unwrap_or_else(|| git_dir.to_path_buf())
156        } else {
157            git_dir.to_path_buf()
158        }
159    }
160
161    impl SyncS3Config {
162        pub(crate) fn resolve_repo_name(&self, repo_root: &Path) -> Result<String> {
163            if let Some(name) = &self.repo {
164                return Ok(name.clone());
165            }
166            repo_root
167                .file_name()
168                .map(|s| s.to_string_lossy().to_string())
169                .ok_or_else(|| GitCryptError::Other("Could not determine repository name".into()))
170        }
171
172        fn region(&self) -> Result<Region> {
173            match (&self.endpoint, self.region.as_deref()) {
174                (Some(endpoint), Some(region_name)) => Ok(Region::Custom {
175                    region: region_name.to_string(),
176                    endpoint: endpoint.to_string(),
177                }),
178                (Some(endpoint), None) => Ok(Region::Custom {
179                    region: "custom".into(),
180                    endpoint: endpoint.to_string(),
181                }),
182                (None, Some(region)) => region
183                    .parse()
184                    .map_err(|_| GitCryptError::Other(format!("Invalid region: {region}"))),
185                (None, None) => Ok(Region::UsEast1),
186            }
187        }
188
189        fn credentials(&self) -> Result<Credentials> {
190            Credentials::new(
191                self.access_key.as_deref(),
192                self.secret_key.as_deref(),
193                None,
194                None,
195                None,
196            )
197            .map_err(|err| GitCryptError::Other(format!("S3 credentials error: {err}")))
198        }
199
200        fn bucket(&self) -> Result<Bucket> {
201            let region = self.region()?;
202            let credentials = self.credentials()?;
203            let bucket = Bucket::new(self.bucket.as_str(), region, credentials)
204                .map_err(|err| GitCryptError::Other(format!("S3 bucket error: {err}")))?;
205            if self.path_style {
206                Ok(*bucket.with_path_style())
207            } else {
208                Ok(*bucket)
209            }
210        }
211
212        fn remote_path(&self, repo: &str, alias: &str) -> String {
213            format!("{}/{}/keys/age/{}.age", self.scope, repo, alias)
214        }
215
216        fn upload(&self, repo: &str, alias: &str, bytes: &[u8]) -> Result<()> {
217            let remote_path = self.remote_path(repo, alias);
218            let bucket = self.bucket()?;
219            bucket
220                .put_object_blocking(remote_path.as_str(), bytes)
221                .map_err(|err| GitCryptError::Other(format!("Failed to upload to S3: {err}")))?;
222            println!("Uploaded age key to s3://{}/{remote_path}", self.bucket);
223            Ok(())
224        }
225
226    }
227
228    #[cfg(test)]
229    mod tests {
230        use super::*;
231        use tempfile::TempDir;
232
233        #[test]
234        #[serial_test::serial]
235        fn load_config_none_when_missing() {
236            use std::env;
237            // Clear any env vars that might interfere
238            let vars_to_clear = [
239                format!("{ENV_PREFIX}BUCKET"),
240                format!("{ENV_PREFIX}SCOPE"),
241            ];
242            for var in &vars_to_clear {
243                env::remove_var(var);
244            }
245
246            let temp = TempDir::new().unwrap();
247            assert!(load_config(temp.path()).unwrap().is_none());
248        }
249
250        #[test]
251        fn load_config_parses_all_fields() {
252            let temp = TempDir::new().unwrap();
253            std::fs::write(
254                temp.path().join(".git-crypt.toml"),
255                r#"
256                    [sync_s3]
257                    enabled = true
258                    bucket = "git-crypt"
259                    scope = "team"
260                    repo = "demo"
261                    region = "us-west-2"
262                    endpoint = "http://localhost:9000"
263                    access_key = "minio"
264                    secret_key = "secret"
265                    path_style = true
266                "#,
267            )
268            .unwrap();
269
270            let cfg = load_config(temp.path()).unwrap().unwrap();
271            assert!(cfg.enabled);
272            assert_eq!(cfg.bucket, "git-crypt");
273            assert_eq!(cfg.scope, "team");
274            assert_eq!(cfg.repo.as_deref(), Some("demo"));
275            assert_eq!(cfg.region.as_deref(), Some("us-west-2"));
276            assert_eq!(cfg.endpoint.as_deref(), Some("http://localhost:9000"));
277            assert_eq!(cfg.access_key.as_deref(), Some("minio"));
278            assert_eq!(cfg.secret_key.as_deref(), Some("secret"));
279            assert!(cfg.path_style);
280            assert_eq!(
281                cfg.remote_path("demo", "alice"),
282                "team/demo/keys/age/alice.age"
283            );
284        }
285
286        #[test]
287        #[serial_test::serial]
288        fn repo_name_defaults_to_dir_name() {
289            use std::env;
290            // Clear any env vars that might interfere
291            env::remove_var(&format!("{ENV_PREFIX}REPO"));
292
293            let temp = TempDir::new().unwrap();
294            std::fs::write(
295                temp.path().join(".git-crypt.toml"),
296                r#"
297                    [sync_s3]
298                    bucket = "git-crypt"
299                    scope = "team"
300                "#,
301            )
302            .unwrap();
303            let cfg = load_config(temp.path()).unwrap().unwrap();
304            assert!(cfg.repo.is_none());
305            let repo_name = cfg.resolve_repo_name(temp.path()).unwrap();
306            // The repo name should be the directory name
307            let expected_name = temp.path().file_name().unwrap().to_string_lossy();
308            assert_eq!(repo_name, expected_name);
309        }
310
311        #[test]
312        #[serial_test::serial]
313        fn env_only_config_is_loaded() {
314            use std::env;
315
316            // Set up test environment variables
317            // Note: config crate with separator "_" will convert GIT_CRYPT_SYNC_S3_BUCKET
318            // to nested structure sync_s3.bucket
319            let test_vars = [
320                ("GIT_CRYPT_SYNC_S3_BUCKET", "git-crypt"),
321                ("GIT_CRYPT_SYNC_S3_SCOPE", "team"),
322                ("GIT_CRYPT_SYNC_S3_REPO", "demo"),
323                ("GIT_CRYPT_SYNC_S3_ENABLED", "true"),
324                ("GIT_CRYPT_SYNC_S3_PATH_STYLE", "true"),
325            ];
326
327            // Set environment variables
328            for (key, value) in &test_vars {
329                env::set_var(key, value);
330            }
331
332            let temp = TempDir::new().unwrap();
333            let cfg = load_config(temp.path()).unwrap().unwrap();
334
335            assert_eq!(cfg.bucket, "git-crypt");
336            assert_eq!(cfg.scope, "team");
337            assert_eq!(cfg.repo.as_deref(), Some("demo"));
338            assert!(cfg.enabled);
339            assert!(cfg.path_style);
340
341            // Clean up environment variables
342            for (key, _) in &test_vars {
343                env::remove_var(key);
344            }
345        }
346    }
347}
348
349#[cfg(not(feature = "sync-s3"))]
350pub fn maybe_sync_age_key(_git_dir: &Path, _age_file: &Path, _alias: &str) -> Result<()> {
351    Ok(())
352}
353
354#[cfg(feature = "sync-s3")]
355pub use s3sync::maybe_sync_age_key;