Skip to main content

redisctl_core/cloud/
env_delivery.rs

1//! Credential delivery to a dotenv file (PRD §4.4/§5.3).
2//!
3//! The `quick-database` workflow never prints the connection string to stdout/stderr; it
4//! writes it to a file so it can't leak into terminal scrollback, CI logs, or an agent's
5//! captured output. This module owns that file I/O and the git-hygiene follow-up, with no
6//! dependency on the API layer so it can be unit-tested in isolation.
7
8use 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/// What `deliver`/`deliver_vars` did, for reporting and tests.
43#[derive(Debug, Clone)]
44pub struct DeliveryOutcome {
45    /// The file that was written.
46    pub path: PathBuf,
47    /// The primary variable name written into it (the first of the set).
48    pub variable: String,
49    /// True when the target file did not exist and was created fresh.
50    pub created: bool,
51    /// True when at least one existing `VAR=` line was replaced (re-run / reuse path).
52    pub replaced: bool,
53}
54
55/// Whether `name` can be written as a dotenv key.
56///
57/// The keys reach the file through `format!("{k}={v}")`, so a name carrying a newline or an `=`
58/// would write assignments of its own. Callers validate their own input for a better error; this
59/// is what makes it impossible to skip.
60pub 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
69/// Whether `value` can be written as a dotenv value.
70///
71/// The other half of `format!("{k}={v}")`: a value carrying a line break ends the assignment and
72/// writes whatever follows as a line of its own. Values here are upstream-supplied — a database
73/// password can be set in the Redis Cloud console, and the broken-out password field is delivered
74/// raw so apps can use it directly — so the caller is not the only one who decides what arrives.
75pub fn is_env_var_value(value: &str) -> bool {
76    !value.contains('\n') && !value.contains('\r')
77}
78
79/// Append (or replace) a single `variable=value` in the dotenv file at `path`.
80/// Convenience wrapper over [`deliver_vars`].
81pub fn deliver(path: &Path, variable: &str, value: &str) -> Result<DeliveryOutcome, DeliveryError> {
82    deliver_vars(path, &[(variable, value)])
83}
84
85/// Write a set of `KEY=value` pairs into the dotenv file at `path` in one pass.
86///
87/// - target exists → each existing `KEY=` line is replaced in place rather than duplicated
88///   (idempotent re-run), all other lines are preserved byte-for-byte, and any genuinely new
89///   keys are appended under a single `# --- added by redisctl … ---` marker. The file is
90///   only rewritten when the content actually changes.
91/// - target missing → created with mode `0o600` (unix), a marker, and all pairs.
92///
93/// No backups are made: unrelated lines are never touched, so the only thing an overwrite
94/// changes is the managed keys — exactly what the caller asked for.
95///
96/// `vars` must be non-empty; the first pair is treated as the primary variable for reporting.
97pub 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    // The value is not quoted or escaped on the way in, so a line break in one would write the
107    // rest as its own assignment — and `upsert_vars` would not match that line on a re-run, so
108    // it would outlive every later delivery.
109    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        // The file holds credentials now — ensure it isn't group/world-readable, even if it
134        // pre-existed with looser permissions (a fresh file is created 0600 below, but an
135        // existing 0644 .env would otherwise stay readable after we write the password).
136        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
158/// If `path` sits inside a git working tree and isn't already ignored, append it to the
159/// repo-root `.gitignore`. Outside a git repo this is a silent no-op (PRD §7.2). Returns
160/// `true` iff `.gitignore` was modified.
161pub 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); // not a git repo → skip
173    };
174
175    // Entry to add: path relative to the repo root when possible, else the bare file name.
176    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
213/// Replace each existing `KEY=…` line in place; append the remaining (new) keys under a
214/// single marker. Returns the new content and whether at least one line was replaced. All
215/// non-matching lines are preserved byte-for-byte.
216fn 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    // Preserve a trailing newline if the original had one (byte-for-byte for the kept lines).
243    if original.ends_with('\n') {
244        out.push('\n');
245    }
246
247    // Append any keys that had no existing line, under one marker.
248    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// Windows has no POSIX mode bits; create the file normally and rely on the user profile's
276// ACLs. Documented limitation (PRD §5.3).
277#[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/// Tighten an existing credentials file to owner-only (`0o600`) on unix. No-op elsewhere.
283#[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    // `start` is a file path; begin at its directory.
297    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        // Writing secrets into a pre-existing world-readable file must tighten it.
345        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        // Unrelated lines preserved byte-for-byte at the head; no backup file created.
371        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        // Re-run with new values: every key is replaced in place, none duplicated.
398        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        // Identical re-run: content stable, no extra files.
421        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    /// A duplicate key must not survive the rewrite: dotenv loaders honour the last occurrence,
475    /// so a leftover second line would silently win with the stale value.
476    #[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    /// The keys are interpolated as `KEY=value`, so a name carrying a newline or an `=` would
506    /// write assignments of its own. Refused here, whatever the caller validated.
507    #[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        // A later pair is checked too, not just the primary one.
530        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    /// The value is interpolated unquoted, so a line break in one writes an assignment of its
536    /// own. Values are upstream-supplied (a console-set database password reaches the raw
537    /// `_PASSWORD` field), so the key check alone does not close the hole.
538    #[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        // A later pair is checked too, and a rejected set writes nothing at all.
553        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        // An existing file is left untouched rather than half-updated.
562        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}