1use std::fs;
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum DeliveryError {
16 #[error("failed to access {path}: {source}")]
17 Io {
18 path: String,
19 #[source]
20 source: std::io::Error,
21 },
22 #[error("refusing to write credentials to {path}: {reason}")]
23 Refused { path: String, reason: String },
24}
25
26impl DeliveryError {
27 fn io(path: &Path, source: std::io::Error) -> Self {
28 Self::Io {
29 path: path.display().to_string(),
30 source,
31 }
32 }
33
34 fn refused(path: &Path, reason: &str) -> Self {
35 Self::Refused {
36 path: path.display().to_string(),
37 reason: reason.to_string(),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct DeliveryOutcome {
45 pub path: PathBuf,
47 pub variable: String,
49 pub created: bool,
51 pub replaced: bool,
53}
54
55pub fn is_env_var_name(name: &str) -> bool {
61 !name.is_empty()
62 && name
63 .chars()
64 .next()
65 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
66 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
67}
68
69pub fn is_env_var_value(value: &str) -> bool {
76 !value.contains('\n') && !value.contains('\r')
77}
78
79pub fn deliver(path: &Path, variable: &str, value: &str) -> Result<DeliveryOutcome, DeliveryError> {
82 deliver_vars(path, &[(variable, value)])
83}
84
85pub fn deliver_vars(path: &Path, vars: &[(&str, &str)]) -> Result<DeliveryOutcome, DeliveryError> {
98 let primary = vars.first().map(|(k, _)| k.to_string()).unwrap_or_default();
99
100 if let Some((key, _)) = vars.iter().find(|(k, _)| !is_env_var_name(k)) {
101 return Err(DeliveryError::refused(
102 path,
103 &format!("'{key}' is not an environment variable name"),
104 ));
105 }
106 if let Some((key, _)) = vars.iter().find(|(_, v)| !is_env_var_value(v)) {
110 return Err(DeliveryError::refused(
111 path,
112 &format!("the value for '{key}' contains a line break"),
113 ));
114 }
115
116 if fs::symlink_metadata(path)
117 .map(|m| m.file_type().is_symlink())
118 .unwrap_or(false)
119 {
120 return Err(DeliveryError::refused(
121 path,
122 "the path is a symlink; writing credentials through it could target a file you did \
123 not choose",
124 ));
125 }
126
127 if path.exists() {
128 let original = fs::read_to_string(path).map_err(|e| DeliveryError::io(path, e))?;
129 let (rewritten, replaced) = upsert_vars(&original, vars);
130 if rewritten != original {
131 fs::write(path, &rewritten).map_err(|e| DeliveryError::io(path, e))?;
132 }
133 restrict_permissions(path)?;
137 Ok(DeliveryOutcome {
138 path: path.to_path_buf(),
139 variable: primary,
140 created: false,
141 replaced,
142 })
143 } else {
144 let mut contents = format!("{}\n", marker_comment());
145 for (k, v) in vars {
146 contents.push_str(&format!("{k}={v}\n"));
147 }
148 write_new_private(path, &contents)?;
149 Ok(DeliveryOutcome {
150 path: path.to_path_buf(),
151 variable: primary,
152 created: true,
153 replaced: false,
154 })
155 }
156}
157
158pub fn ensure_gitignored(path: &Path) -> Result<bool, DeliveryError> {
162 let abs = if path.is_absolute() {
163 path.to_path_buf()
164 } else {
165 match std::env::current_dir() {
166 Ok(cwd) => cwd.join(path),
167 Err(_) => return Ok(false),
168 }
169 };
170
171 let Some(repo_root) = find_repo_root(&abs) else {
172 return Ok(false); };
174
175 let entry = abs
177 .strip_prefix(&repo_root)
178 .ok()
179 .map(|p| p.to_string_lossy().replace('\\', "/"))
180 .or_else(|| abs.file_name().map(|n| n.to_string_lossy().into_owned()))
181 .unwrap_or_default();
182 if entry.is_empty() {
183 return Ok(false);
184 }
185 let file_name = abs
186 .file_name()
187 .map(|n| n.to_string_lossy().into_owned())
188 .unwrap_or_default();
189
190 let gitignore = repo_root.join(".gitignore");
191 let existing = fs::read_to_string(&gitignore).unwrap_or_default();
192 if already_ignored(&existing, &entry, &file_name) {
193 return Ok(false);
194 }
195
196 let mut contents = existing;
197 if !contents.is_empty() && !contents.ends_with('\n') {
198 contents.push('\n');
199 }
200 contents.push_str(&entry);
201 contents.push('\n');
202 fs::write(&gitignore, &contents).map_err(|e| DeliveryError::io(&gitignore, e))?;
203 Ok(true)
204}
205
206fn marker_comment() -> String {
207 format!(
208 "# --- added by redisctl on {} ---",
209 chrono::Local::now().format("%Y-%m-%d")
210 )
211}
212
213fn upsert_vars(original: &str, vars: &[(&str, &str)]) -> (String, bool) {
217 let mut remaining: Vec<(&str, &str)> = vars.to_vec();
218 let mut replaced_any = false;
219 let mut out_lines: Vec<String> = Vec::new();
220 let mut written: Vec<&str> = Vec::new();
221
222 for line in original.lines() {
223 let trimmed = line.trim_start();
224 match vars
225 .iter()
226 .find(|(k, _)| trimmed.starts_with(&format!("{k}=")))
227 {
228 Some((k, v)) => {
229 remaining.retain(|(rk, _)| rk != k);
230 replaced_any = true;
231 if written.contains(k) {
232 continue;
233 }
234 written.push(k);
235 out_lines.push(format!("{k}={v}"));
236 }
237 None => out_lines.push(line.to_string()),
238 }
239 }
240
241 let mut out = out_lines.join("\n");
242 if original.ends_with('\n') {
244 out.push('\n');
245 }
246
247 if !remaining.is_empty() {
249 if !out.is_empty() && !out.ends_with('\n') {
250 out.push('\n');
251 }
252 out.push_str(&marker_comment());
253 out.push('\n');
254 for (k, v) in &remaining {
255 out.push_str(&format!("{k}={v}\n"));
256 }
257 }
258
259 (out, replaced_any)
260}
261
262#[cfg(unix)]
263fn write_new_private(path: &Path, contents: &str) -> Result<(), DeliveryError> {
264 use std::os::unix::fs::OpenOptionsExt;
265 let mut file = fs::OpenOptions::new()
266 .write(true)
267 .create_new(true)
268 .mode(0o600)
269 .open(path)
270 .map_err(|e| DeliveryError::io(path, e))?;
271 file.write_all(contents.as_bytes())
272 .map_err(|e| DeliveryError::io(path, e))
273}
274
275#[cfg(not(unix))]
278fn write_new_private(path: &Path, contents: &str) -> Result<(), DeliveryError> {
279 fs::write(path, contents).map_err(|e| DeliveryError::io(path, e))
280}
281
282#[cfg(unix)]
284fn restrict_permissions(path: &Path) -> Result<(), DeliveryError> {
285 use std::os::unix::fs::PermissionsExt;
286 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
287 .map_err(|e| DeliveryError::io(path, e))
288}
289
290#[cfg(not(unix))]
291fn restrict_permissions(_path: &Path) -> Result<(), DeliveryError> {
292 Ok(())
293}
294
295fn find_repo_root(start: &Path) -> Option<PathBuf> {
296 let mut dir = start.parent();
298 while let Some(d) = dir {
299 if d.join(".git").exists() {
300 return Some(d.to_path_buf());
301 }
302 dir = d.parent();
303 }
304 None
305}
306
307fn already_ignored(gitignore: &str, entry: &str, file_name: &str) -> bool {
308 gitignore.lines().any(|line| {
309 let l = line.trim();
310 if l.is_empty() || l.starts_with('#') {
311 return false;
312 }
313 let l = l.trim_start_matches("./");
314 l == entry || l == file_name
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::fs;
322 use tempfile::tempdir;
323
324 #[test]
325 fn creates_fresh_file_with_marker() {
326 let dir = tempdir().unwrap();
327 let env = dir.path().join(".env");
328 let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
329 assert!(out.created);
330 assert!(!out.replaced);
331 let body = fs::read_to_string(&env).unwrap();
332 assert!(body.contains("REDIS_URL=rediss://x@h:1"));
333 assert!(body.contains("added by redisctl"));
334 }
335
336 #[cfg(unix)]
337 #[test]
338 fn existing_loose_file_is_tightened_to_0600() {
339 use std::os::unix::fs::PermissionsExt;
340 let dir = tempdir().unwrap();
341 let env = dir.path().join(".env");
342 fs::write(&env, "EXISTING=1\n").unwrap();
343 fs::set_permissions(&env, fs::Permissions::from_mode(0o644)).unwrap();
344 deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
346 let mode = fs::metadata(&env).unwrap().permissions().mode() & 0o777;
347 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
348 }
349
350 #[cfg(unix)]
351 #[test]
352 fn fresh_file_is_0600() {
353 use std::os::unix::fs::PermissionsExt;
354 let dir = tempdir().unwrap();
355 let env = dir.path().join(".env");
356 deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
357 let mode = fs::metadata(&env).unwrap().permissions().mode() & 0o777;
358 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
359 }
360
361 #[test]
362 fn existing_file_is_appended_preserving_others() {
363 let dir = tempdir().unwrap();
364 let env = dir.path().join(".env");
365 fs::write(&env, "EXISTING=1\nOTHER=two\n").unwrap();
366 let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
367 assert!(!out.created);
368 assert!(!out.replaced);
369 let body = fs::read_to_string(&env).unwrap();
370 assert!(body.starts_with("EXISTING=1\nOTHER=two\n"));
372 assert!(body.contains("REDIS_URL=rediss://x@h:1"));
373 assert_eq!(
374 fs::read_dir(dir.path()).unwrap().count(),
375 1,
376 "only .env exists, no backup"
377 );
378 }
379
380 #[test]
381 fn writes_multiple_vars_and_reruns_cleanly() {
382 let dir = tempdir().unwrap();
383 let env = dir.path().join(".env");
384 let vars = [
385 ("REDIS_URL", "rediss://default:p@h:1"),
386 ("REDIS_HOST", "h"),
387 ("REDIS_PORT", "1"),
388 ];
389 let out = deliver_vars(&env, &vars).unwrap();
390 assert!(out.created);
391 assert_eq!(out.variable, "REDIS_URL");
392 let body = fs::read_to_string(&env).unwrap();
393 for (k, v) in vars {
394 assert!(body.contains(&format!("{k}={v}")), "missing {k}");
395 }
396
397 let vars2 = [
399 ("REDIS_URL", "rediss://default:p2@h2:2"),
400 ("REDIS_HOST", "h2"),
401 ("REDIS_PORT", "2"),
402 ];
403 let out2 = deliver_vars(&env, &vars2).unwrap();
404 assert!(out2.replaced);
405 let body2 = fs::read_to_string(&env).unwrap();
406 assert_eq!(body2.matches("REDIS_URL=").count(), 1);
407 assert_eq!(body2.matches("REDIS_HOST=").count(), 1);
408 assert_eq!(body2.matches("REDIS_PORT=").count(), 1);
409 assert!(body2.contains("REDIS_HOST=h2"));
410 assert!(!body2.contains("REDIS_HOST=h\n"));
411 }
412
413 #[test]
414 fn unchanged_rerun_is_noop() {
415 let dir = tempdir().unwrap();
416 let env = dir.path().join(".env");
417 deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
418 let before = fs::read_to_string(&env).unwrap();
419
420 let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
422 assert!(!out.created);
423 assert_eq!(fs::read_to_string(&env).unwrap(), before);
424 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
425 }
426
427 #[test]
428 fn rerun_replaces_not_duplicates() {
429 let dir = tempdir().unwrap();
430 let env = dir.path().join(".env");
431 deliver(&env, "REDIS_URL", "rediss://old@h:1").unwrap();
432 let out = deliver(&env, "REDIS_URL", "rediss://new@h:2").unwrap();
433 assert!(out.replaced);
434 let body = fs::read_to_string(&env).unwrap();
435 assert_eq!(body.matches("REDIS_URL=").count(), 1, "no duplicate var");
436 assert!(body.contains("REDIS_URL=rediss://new@h:2"));
437 assert!(!body.contains("rediss://old@h:1"));
438 }
439
440 #[test]
441 fn gitignore_appended_in_repo() {
442 let dir = tempdir().unwrap();
443 fs::create_dir(dir.path().join(".git")).unwrap();
444 let env = dir.path().join(".env");
445 fs::write(&env, "A=1\n").unwrap();
446 let changed = ensure_gitignored(&env).unwrap();
447 assert!(changed);
448 let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
449 assert!(gi.lines().any(|l| l.trim() == ".env"));
450 }
451
452 #[test]
453 fn gitignore_skipped_outside_repo() {
454 let dir = tempdir().unwrap();
455 let env = dir.path().join(".env");
456 fs::write(&env, "A=1\n").unwrap();
457 assert!(!ensure_gitignored(&env).unwrap());
458 assert!(!dir.path().join(".gitignore").exists());
459 }
460
461 #[test]
462 fn gitignore_not_duplicated_when_already_ignored() {
463 let dir = tempdir().unwrap();
464 fs::create_dir(dir.path().join(".git")).unwrap();
465 fs::write(dir.path().join(".gitignore"), "node_modules\n.env\n").unwrap();
466 let env = dir.path().join(".env");
467 fs::write(&env, "A=1\n").unwrap();
468 let changed = ensure_gitignored(&env).unwrap();
469 assert!(!changed);
470 let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
471 assert_eq!(gi.matches(".env").count(), 1);
472 }
473
474 #[test]
477 fn upsert_replaces_every_occurrence_of_a_key() {
478 let original = "REDIS_URL=old-one\nOTHER=keep\nREDIS_URL=old-two\n";
479 let (out, replaced) = upsert_vars(original, &[("REDIS_URL", "new")]);
480 assert!(replaced);
481 assert_eq!(out.matches("REDIS_URL=").count(), 1, "got {out:?}");
482 assert!(out.contains("REDIS_URL=new"));
483 assert!(!out.contains("old-two"));
484 assert!(out.contains("OTHER=keep"), "unrelated lines are preserved");
485 }
486
487 #[cfg(unix)]
488 #[test]
489 fn deliver_refuses_a_symlink() {
490 let dir = tempfile::tempdir().unwrap();
491 let target = dir.path().join("real-file");
492 std::fs::write(&target, "SECRET=already-here\n").unwrap();
493 let link = dir.path().join("link.env");
494 std::os::unix::fs::symlink(&target, &link).unwrap();
495
496 let err = deliver_vars(&link, &[("REDIS_URL", "redis://x")]).unwrap_err();
497 assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
498 assert_eq!(
499 std::fs::read_to_string(&target).unwrap(),
500 "SECRET=already-here\n",
501 "the symlink target must be untouched"
502 );
503 }
504
505 #[test]
508 fn deliver_refuses_a_key_that_is_not_an_env_var_name() {
509 let dir = tempfile::tempdir().unwrap();
510 let path = dir.path().join(".env");
511
512 for bad in [
513 "A\nINJECTED=1",
514 "A\r\nINJECTED=1",
515 "REDIS_URL=x",
516 "HAS-DASH",
517 "HAS SPACE",
518 "1LEADING",
519 "",
520 ] {
521 let err = deliver_vars(&path, &[(bad, "redis://x")]).unwrap_err();
522 assert!(
523 matches!(err, DeliveryError::Refused { .. }),
524 "{bad:?} gave {err:?}"
525 );
526 assert!(!path.exists(), "{bad:?} created a file");
527 }
528
529 let err = deliver_vars(&path, &[("REDIS_URL", "redis://x"), ("A\nB", "y")]).unwrap_err();
531 assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
532 assert!(!path.exists(), "a rejected set wrote a file anyway");
533 }
534
535 #[test]
539 fn deliver_refuses_a_value_carrying_a_line_break() {
540 let dir = tempfile::tempdir().unwrap();
541 let path = dir.path().join(".env");
542
543 for bad in ["p\nINJECTED=owned", "p\r\nINJECTED=owned", "p\r", "\n"] {
544 let err = deliver_vars(&path, &[("REDIS_PASSWORD", bad)]).unwrap_err();
545 assert!(
546 matches!(err, DeliveryError::Refused { .. }),
547 "{bad:?} gave {err:?}"
548 );
549 assert!(!path.exists(), "{bad:?} created a file");
550 }
551
552 let err = deliver_vars(
554 &path,
555 &[("REDIS_URL", "redis://x"), ("REDIS_PASSWORD", "p\nX=1")],
556 )
557 .unwrap_err();
558 assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
559 assert!(!path.exists(), "a rejected set wrote a file anyway");
560
561 deliver_vars(&path, &[("REDIS_URL", "redis://ok")]).unwrap();
563 let before = fs::read_to_string(&path).unwrap();
564 assert!(deliver_vars(&path, &[("REDIS_URL", "redis://x\nX=1")]).is_err());
565 assert_eq!(fs::read_to_string(&path).unwrap(), before);
566 }
567}