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 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 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 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 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 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 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 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 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 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 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;