use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DeliveryError {
#[error("failed to access {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("refusing to write credentials to {path}: {reason}")]
Refused { path: String, reason: String },
}
impl DeliveryError {
fn io(path: &Path, source: std::io::Error) -> Self {
Self::Io {
path: path.display().to_string(),
source,
}
}
fn refused(path: &Path, reason: &str) -> Self {
Self::Refused {
path: path.display().to_string(),
reason: reason.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct DeliveryOutcome {
pub path: PathBuf,
pub variable: String,
pub created: bool,
pub replaced: bool,
}
pub fn is_env_var_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn is_env_var_value(value: &str) -> bool {
!value.contains('\n') && !value.contains('\r')
}
pub fn deliver(path: &Path, variable: &str, value: &str) -> Result<DeliveryOutcome, DeliveryError> {
deliver_vars(path, &[(variable, value)])
}
pub fn deliver_vars(path: &Path, vars: &[(&str, &str)]) -> Result<DeliveryOutcome, DeliveryError> {
let primary = vars.first().map(|(k, _)| k.to_string()).unwrap_or_default();
if let Some((key, _)) = vars.iter().find(|(k, _)| !is_env_var_name(k)) {
return Err(DeliveryError::refused(
path,
&format!("'{key}' is not an environment variable name"),
));
}
if let Some((key, _)) = vars.iter().find(|(_, v)| !is_env_var_value(v)) {
return Err(DeliveryError::refused(
path,
&format!("the value for '{key}' contains a line break"),
));
}
if fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err(DeliveryError::refused(
path,
"the path is a symlink; writing credentials through it could target a file you did \
not choose",
));
}
if path.exists() {
let original = fs::read_to_string(path).map_err(|e| DeliveryError::io(path, e))?;
let (rewritten, replaced) = upsert_vars(&original, vars);
if rewritten != original {
fs::write(path, &rewritten).map_err(|e| DeliveryError::io(path, e))?;
}
restrict_permissions(path)?;
Ok(DeliveryOutcome {
path: path.to_path_buf(),
variable: primary,
created: false,
replaced,
})
} else {
let mut contents = format!("{}\n", marker_comment());
for (k, v) in vars {
contents.push_str(&format!("{k}={v}\n"));
}
write_new_private(path, &contents)?;
Ok(DeliveryOutcome {
path: path.to_path_buf(),
variable: primary,
created: true,
replaced: false,
})
}
}
pub fn ensure_gitignored(path: &Path) -> Result<bool, DeliveryError> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => return Ok(false),
}
};
let Some(repo_root) = find_repo_root(&abs) else {
return Ok(false); };
let entry = abs
.strip_prefix(&repo_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.or_else(|| abs.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_default();
if entry.is_empty() {
return Ok(false);
}
let file_name = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let gitignore = repo_root.join(".gitignore");
let existing = fs::read_to_string(&gitignore).unwrap_or_default();
if already_ignored(&existing, &entry, &file_name) {
return Ok(false);
}
let mut contents = existing;
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(&entry);
contents.push('\n');
fs::write(&gitignore, &contents).map_err(|e| DeliveryError::io(&gitignore, e))?;
Ok(true)
}
fn marker_comment() -> String {
format!(
"# --- added by redisctl on {} ---",
chrono::Local::now().format("%Y-%m-%d")
)
}
fn upsert_vars(original: &str, vars: &[(&str, &str)]) -> (String, bool) {
let mut remaining: Vec<(&str, &str)> = vars.to_vec();
let mut replaced_any = false;
let mut out_lines: Vec<String> = Vec::new();
let mut written: Vec<&str> = Vec::new();
for line in original.lines() {
let trimmed = line.trim_start();
match vars
.iter()
.find(|(k, _)| trimmed.starts_with(&format!("{k}=")))
{
Some((k, v)) => {
remaining.retain(|(rk, _)| rk != k);
replaced_any = true;
if written.contains(k) {
continue;
}
written.push(k);
out_lines.push(format!("{k}={v}"));
}
None => out_lines.push(line.to_string()),
}
}
let mut out = out_lines.join("\n");
if original.ends_with('\n') {
out.push('\n');
}
if !remaining.is_empty() {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&marker_comment());
out.push('\n');
for (k, v) in &remaining {
out.push_str(&format!("{k}={v}\n"));
}
}
(out, replaced_any)
}
#[cfg(unix)]
fn write_new_private(path: &Path, contents: &str) -> Result<(), DeliveryError> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| DeliveryError::io(path, e))?;
file.write_all(contents.as_bytes())
.map_err(|e| DeliveryError::io(path, e))
}
#[cfg(not(unix))]
fn write_new_private(path: &Path, contents: &str) -> Result<(), DeliveryError> {
fs::write(path, contents).map_err(|e| DeliveryError::io(path, e))
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) -> Result<(), DeliveryError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|e| DeliveryError::io(path, e))
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &Path) -> Result<(), DeliveryError> {
Ok(())
}
fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut dir = start.parent();
while let Some(d) = dir {
if d.join(".git").exists() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
fn already_ignored(gitignore: &str, entry: &str, file_name: &str) -> bool {
gitignore.lines().any(|line| {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
return false;
}
let l = l.trim_start_matches("./");
l == entry || l == file_name
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn creates_fresh_file_with_marker() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
assert!(out.created);
assert!(!out.replaced);
let body = fs::read_to_string(&env).unwrap();
assert!(body.contains("REDIS_URL=rediss://x@h:1"));
assert!(body.contains("added by redisctl"));
}
#[cfg(unix)]
#[test]
fn existing_loose_file_is_tightened_to_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
fs::write(&env, "EXISTING=1\n").unwrap();
fs::set_permissions(&env, fs::Permissions::from_mode(0o644)).unwrap();
deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
let mode = fs::metadata(&env).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn fresh_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
let mode = fs::metadata(&env).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
fn existing_file_is_appended_preserving_others() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
fs::write(&env, "EXISTING=1\nOTHER=two\n").unwrap();
let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
assert!(!out.created);
assert!(!out.replaced);
let body = fs::read_to_string(&env).unwrap();
assert!(body.starts_with("EXISTING=1\nOTHER=two\n"));
assert!(body.contains("REDIS_URL=rediss://x@h:1"));
assert_eq!(
fs::read_dir(dir.path()).unwrap().count(),
1,
"only .env exists, no backup"
);
}
#[test]
fn writes_multiple_vars_and_reruns_cleanly() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
let vars = [
("REDIS_URL", "rediss://default:p@h:1"),
("REDIS_HOST", "h"),
("REDIS_PORT", "1"),
];
let out = deliver_vars(&env, &vars).unwrap();
assert!(out.created);
assert_eq!(out.variable, "REDIS_URL");
let body = fs::read_to_string(&env).unwrap();
for (k, v) in vars {
assert!(body.contains(&format!("{k}={v}")), "missing {k}");
}
let vars2 = [
("REDIS_URL", "rediss://default:p2@h2:2"),
("REDIS_HOST", "h2"),
("REDIS_PORT", "2"),
];
let out2 = deliver_vars(&env, &vars2).unwrap();
assert!(out2.replaced);
let body2 = fs::read_to_string(&env).unwrap();
assert_eq!(body2.matches("REDIS_URL=").count(), 1);
assert_eq!(body2.matches("REDIS_HOST=").count(), 1);
assert_eq!(body2.matches("REDIS_PORT=").count(), 1);
assert!(body2.contains("REDIS_HOST=h2"));
assert!(!body2.contains("REDIS_HOST=h\n"));
}
#[test]
fn unchanged_rerun_is_noop() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
let before = fs::read_to_string(&env).unwrap();
let out = deliver(&env, "REDIS_URL", "rediss://x@h:1").unwrap();
assert!(!out.created);
assert_eq!(fs::read_to_string(&env).unwrap(), before);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn rerun_replaces_not_duplicates() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
deliver(&env, "REDIS_URL", "rediss://old@h:1").unwrap();
let out = deliver(&env, "REDIS_URL", "rediss://new@h:2").unwrap();
assert!(out.replaced);
let body = fs::read_to_string(&env).unwrap();
assert_eq!(body.matches("REDIS_URL=").count(), 1, "no duplicate var");
assert!(body.contains("REDIS_URL=rediss://new@h:2"));
assert!(!body.contains("rediss://old@h:1"));
}
#[test]
fn gitignore_appended_in_repo() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
let env = dir.path().join(".env");
fs::write(&env, "A=1\n").unwrap();
let changed = ensure_gitignored(&env).unwrap();
assert!(changed);
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi.lines().any(|l| l.trim() == ".env"));
}
#[test]
fn gitignore_skipped_outside_repo() {
let dir = tempdir().unwrap();
let env = dir.path().join(".env");
fs::write(&env, "A=1\n").unwrap();
assert!(!ensure_gitignored(&env).unwrap());
assert!(!dir.path().join(".gitignore").exists());
}
#[test]
fn gitignore_not_duplicated_when_already_ignored() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "node_modules\n.env\n").unwrap();
let env = dir.path().join(".env");
fs::write(&env, "A=1\n").unwrap();
let changed = ensure_gitignored(&env).unwrap();
assert!(!changed);
let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(gi.matches(".env").count(), 1);
}
#[test]
fn upsert_replaces_every_occurrence_of_a_key() {
let original = "REDIS_URL=old-one\nOTHER=keep\nREDIS_URL=old-two\n";
let (out, replaced) = upsert_vars(original, &[("REDIS_URL", "new")]);
assert!(replaced);
assert_eq!(out.matches("REDIS_URL=").count(), 1, "got {out:?}");
assert!(out.contains("REDIS_URL=new"));
assert!(!out.contains("old-two"));
assert!(out.contains("OTHER=keep"), "unrelated lines are preserved");
}
#[cfg(unix)]
#[test]
fn deliver_refuses_a_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real-file");
std::fs::write(&target, "SECRET=already-here\n").unwrap();
let link = dir.path().join("link.env");
std::os::unix::fs::symlink(&target, &link).unwrap();
let err = deliver_vars(&link, &[("REDIS_URL", "redis://x")]).unwrap_err();
assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"SECRET=already-here\n",
"the symlink target must be untouched"
);
}
#[test]
fn deliver_refuses_a_key_that_is_not_an_env_var_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
for bad in [
"A\nINJECTED=1",
"A\r\nINJECTED=1",
"REDIS_URL=x",
"HAS-DASH",
"HAS SPACE",
"1LEADING",
"",
] {
let err = deliver_vars(&path, &[(bad, "redis://x")]).unwrap_err();
assert!(
matches!(err, DeliveryError::Refused { .. }),
"{bad:?} gave {err:?}"
);
assert!(!path.exists(), "{bad:?} created a file");
}
let err = deliver_vars(&path, &[("REDIS_URL", "redis://x"), ("A\nB", "y")]).unwrap_err();
assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
assert!(!path.exists(), "a rejected set wrote a file anyway");
}
#[test]
fn deliver_refuses_a_value_carrying_a_line_break() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
for bad in ["p\nINJECTED=owned", "p\r\nINJECTED=owned", "p\r", "\n"] {
let err = deliver_vars(&path, &[("REDIS_PASSWORD", bad)]).unwrap_err();
assert!(
matches!(err, DeliveryError::Refused { .. }),
"{bad:?} gave {err:?}"
);
assert!(!path.exists(), "{bad:?} created a file");
}
let err = deliver_vars(
&path,
&[("REDIS_URL", "redis://x"), ("REDIS_PASSWORD", "p\nX=1")],
)
.unwrap_err();
assert!(matches!(err, DeliveryError::Refused { .. }), "got {err:?}");
assert!(!path.exists(), "a rejected set wrote a file anyway");
deliver_vars(&path, &[("REDIS_URL", "redis://ok")]).unwrap();
let before = fs::read_to_string(&path).unwrap();
assert!(deliver_vars(&path, &[("REDIS_URL", "redis://x\nX=1")]).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), before);
}
}