use std::env;
use std::fs;
use std::io::Write;
use std::path::Path;
use age::secrecy::SecretString;
fn shell_escape(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
if path.is_symlink() {
return Err(format!(
"{label} is a symlink — refusing to follow for security"
));
}
Ok(())
}
fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
reject_symlink(path, label)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = fs::metadata(path) {
let mode = meta.mode();
if mode & WORLD_READABLE_MASK != 0 {
return Err(format!(
"{label} is readable by others (mode {:o}). Run: chmod 600 {}",
mode & 0o777,
path.display()
));
}
}
}
fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
}
pub const ENV_MURK_KEY: &str = "MURK_KEY";
pub const ENV_MURK_KEY_FILE: &str = "MURK_KEY_FILE";
pub const ENV_MURK_VAULT: &str = "MURK_VAULT";
const IMPORT_SKIP: &[&str] = &[ENV_MURK_KEY, ENV_MURK_KEY_FILE, ENV_MURK_VAULT];
#[cfg(unix)]
const SECRET_FILE_MODE: u32 = 0o600;
#[cfg(unix)]
const WORLD_READABLE_MASK: u32 = 0o077;
pub fn resolve_key() -> Result<SecretString, String> {
resolve_key_for_vault(".murk")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
EnvVar,
EnvFile(std::path::PathBuf),
Auto(std::path::PathBuf),
}
impl KeySource {
pub fn describe(&self) -> String {
match self {
KeySource::EnvVar => "MURK_KEY environment variable".into(),
KeySource::EnvFile(p) => format!("MURK_KEY_FILE {}", p.display()),
KeySource::Auto(p) => p.display().to_string(),
}
}
}
pub fn resolve_key_with_source(vault_path: &str) -> Result<(SecretString, KeySource), String> {
if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) {
return Ok((SecretString::from(k), KeySource::EnvVar));
}
if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
let p = std::path::Path::new(&path);
let contents = read_secret_file(p, "MURK_KEY_FILE")?;
return Ok((
SecretString::from(contents.trim().to_string()),
KeySource::EnvFile(p.to_path_buf()),
));
}
if let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists()) {
let contents = read_secret_file(&path, "key file")?;
return Ok((
SecretString::from(contents.trim().to_string()),
KeySource::Auto(path),
));
}
Err(
"MURK_KEY not set. Run `murk init` to generate a key, set MURK_KEY_FILE to point at one, or ask a recipient to authorize you. If your .env contains an inline MURK_KEY or MURK_KEY_FILE, run `direnv allow` (or `source .env`) so it is exported to the environment — murk no longer reads .env directly."
.into(),
)
}
pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
resolve_key_with_source(vault_path).map(|(k, _)| k)
}
pub fn parse_env(contents: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
if key.is_empty() || IMPORT_SKIP.contains(&key) {
continue;
}
pairs.push((key.into(), value.into()));
}
pairs
}
pub fn warn_env_permissions() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let env_path = Path::new(".env");
if env_path.exists()
&& let Ok(meta) = fs::metadata(env_path)
{
let mode = meta.permissions().mode();
if mode & WORLD_READABLE_MASK != 0 {
eprintln!(
"\x1b[1;33mwarning:\x1b[0m .env is readable by others (mode {:o}). Run: \x1b[1mchmod 600 .env\x1b[0m",
mode & 0o777
);
}
}
}
}
pub fn dotenv_has_murk_key() -> bool {
let env_path = Path::new(".env");
if !env_path.exists() {
return false;
}
let contents = fs::read_to_string(env_path).unwrap_or_default();
contents.lines().any(|l| {
l.starts_with("MURK_KEY=")
|| l.starts_with("export MURK_KEY=")
|| l.starts_with("MURK_KEY_FILE=")
|| l.starts_with("export MURK_KEY_FILE=")
})
}
pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
let env_path = Path::new(".env");
reject_symlink(env_path, ".env")?;
let existing = if env_path.exists() {
let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
let filtered: Vec<&str> = contents
.lines()
.filter(|l| !l.starts_with("MURK_KEY=") && !l.starts_with("export MURK_KEY="))
.collect();
filtered.join("\n") + "\n"
} else {
String::new()
};
let full_content = format!("{existing}export MURK_KEY={secret_key}\n");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(SECRET_FILE_MODE)
.custom_flags(libc::O_NOFOLLOW)
.open(env_path)
.map_err(|e| format!("opening .env: {e}"))?;
file.write_all(full_content.as_bytes())
.map_err(|e| format!("writing .env: {e}"))?;
}
#[cfg(not(unix))]
{
fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
}
Ok(())
}
pub fn key_file_path(vault_path: &str) -> Result<std::path::PathBuf, String> {
use sha2::{Digest, Sha256};
let p = std::path::Path::new(vault_path);
let abs_path = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("cannot resolve vault path: {e}"))?
.join(p)
};
let hash = Sha256::digest(abs_path.to_string_lossy().as_bytes());
let short_hash: String = hash.iter().take(8).fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
let config_dir = dirs_path()?;
Ok(config_dir.join(&short_hash))
}
fn dirs_path() -> Result<std::path::PathBuf, String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "cannot determine home directory")?;
let dir = std::path::Path::new(&home)
.join(".config")
.join("murk")
.join("keys");
fs::create_dir_all(&dir).map_err(|e| format!("creating key directory: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let parent = dir.parent().unwrap(); fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|e| format!("setting permissions on {}: {e}", parent.display()))?;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
.map_err(|e| format!("setting permissions on {}: {e}", dir.display()))?;
}
Ok(dir)
}
pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
reject_symlink(path, &path.display().to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(SECRET_FILE_MODE)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|e| format!("writing key file: {e}"))?;
file.write_all(secret_key.as_bytes())
.map_err(|e| format!("writing key file: {e}"))?;
}
#[cfg(not(unix))]
{
fs::write(path, secret_key).map_err(|e| format!("writing key file: {e}"))?;
}
Ok(())
}
pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
let env_path = Path::new(".env");
reject_symlink(env_path, ".env")?;
let existing = if env_path.exists() {
let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
let filtered: Vec<&str> = contents
.lines()
.filter(|l| {
!l.starts_with("MURK_KEY=")
&& !l.starts_with("export MURK_KEY=")
&& !l.starts_with("MURK_KEY_FILE=")
&& !l.starts_with("export MURK_KEY_FILE=")
})
.collect();
filtered.join("\n") + "\n"
} else {
String::new()
};
let full_content = format!(
"{existing}export MURK_KEY_FILE='{}'\n",
key_file_path.display().to_string().replace('\'', "'\\''")
);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(SECRET_FILE_MODE)
.custom_flags(libc::O_NOFOLLOW)
.open(env_path)
.map_err(|e| format!("opening .env: {e}"))?;
file.write_all(full_content.as_bytes())
.map_err(|e| format!("writing .env: {e}"))?;
}
#[cfg(not(unix))]
{
fs::write(env_path, &full_content).map_err(|e| format!("writing .env: {e}"))?;
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum EnvrcStatus {
AlreadyPresent,
Appended,
Created,
}
pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
let envrc = Path::new(".envrc");
reject_symlink(envrc, ".envrc")?;
let safe_vault_name = shell_escape(vault_name);
let murk_line = format!("eval \"$(murk export --vault {safe_vault_name})\"");
if envrc.exists() {
let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
if contents.contains("murk export") {
return Ok(EnvrcStatus::AlreadyPresent);
}
let mut file = fs::OpenOptions::new()
.append(true)
.open(envrc)
.map_err(|e| format!("writing .envrc: {e}"))?;
writeln!(file, "\n{murk_line}").map_err(|e| format!("writing .envrc: {e}"))?;
Ok(EnvrcStatus::Appended)
} else {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(SECRET_FILE_MODE)
.custom_flags(libc::O_NOFOLLOW)
.open(envrc)
.map_err(|e| format!("writing .envrc: {e}"))?;
file.write_all(format!("{murk_line}\n").as_bytes())
.map_err(|e| format!("writing .envrc: {e}"))?;
}
#[cfg(not(unix))]
{
fs::write(envrc, format!("{murk_line}\n"))
.map_err(|e| format!("writing .envrc: {e}"))?;
}
Ok(EnvrcStatus::Created)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{CWD_LOCK, ENV_LOCK};
#[test]
fn parse_env_empty() {
assert!(parse_env("").is_empty());
}
#[test]
fn parse_env_comments_and_blanks() {
let input = "# comment\n\n # another\n";
assert!(parse_env(input).is_empty());
}
#[test]
fn parse_env_basic() {
let input = "FOO=bar\nBAZ=qux\n";
let pairs = parse_env(input);
assert_eq!(
pairs,
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]
);
}
#[test]
fn parse_env_double_quotes() {
let pairs = parse_env("KEY=\"hello world\"\n");
assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
}
#[test]
fn parse_env_single_quotes() {
let pairs = parse_env("KEY='hello world'\n");
assert_eq!(pairs, vec![("KEY".into(), "hello world".into())]);
}
#[test]
fn parse_env_export_prefix() {
let pairs = parse_env("export FOO=bar\n");
assert_eq!(pairs, vec![("FOO".into(), "bar".into())]);
}
#[test]
fn parse_env_skips_murk_keys() {
let input = "MURK_KEY=secret\nMURK_KEY_FILE=/path\nMURK_VAULT=.murk\nKEEP=yes\n";
let pairs = parse_env(input);
assert_eq!(pairs, vec![("KEEP".into(), "yes".into())]);
}
#[test]
fn parse_env_equals_in_value() {
let pairs = parse_env("URL=postgres://host?opt=1\n");
assert_eq!(pairs, vec![("URL".into(), "postgres://host?opt=1".into())]);
}
#[test]
fn parse_env_no_equals_skipped() {
let pairs = parse_env("not-a-valid-line\nKEY=val\n");
assert_eq!(pairs, vec![("KEY".into(), "val".into())]);
}
#[test]
fn parse_env_empty_value() {
let pairs = parse_env("KEY=\n");
assert_eq!(pairs, vec![("KEY".into(), String::new())]);
}
#[test]
fn parse_env_trailing_whitespace() {
let pairs = parse_env("KEY=value \n");
assert_eq!(pairs, vec![("KEY".into(), "value".into())]);
}
#[test]
fn parse_env_unicode_value() {
let pairs = parse_env("KEY=hello🔐world\n");
assert_eq!(pairs, vec![("KEY".into(), "hello🔐world".into())]);
}
#[test]
fn parse_env_empty_key_skipped() {
let pairs = parse_env("=value\n");
assert!(pairs.is_empty());
}
#[test]
fn parse_env_mixed_quotes_unmatched() {
let pairs = parse_env("KEY=\"hello'\n");
assert_eq!(pairs, vec![("KEY".into(), "\"hello'".into())]);
}
#[test]
fn parse_env_multiple_murk_vars() {
let input = "MURK_KEY=x\nMURK_KEY_FILE=y\nMURK_VAULT=z\nA=1\nB=2\n";
let pairs = parse_env(input);
assert_eq!(
pairs,
vec![("A".into(), "1".into()), ("B".into(), "2".into())]
);
}
fn resolve_key_sandbox(
name: &str,
) -> (
std::sync::MutexGuard<'static, ()>,
std::sync::MutexGuard<'static, ()>,
std::path::PathBuf,
std::path::PathBuf,
) {
let env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = std::env::temp_dir().join(format!("murk_test_{name}"));
let _ = std::fs::create_dir_all(&tmp);
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp).unwrap();
(env, cwd, tmp, prev)
}
fn resolve_key_sandbox_teardown(tmp: &std::path::Path, prev: &std::path::Path) {
std::env::set_current_dir(prev).unwrap();
let _ = std::fs::remove_dir_all(tmp);
}
#[test]
fn resolve_key_from_env() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_env");
let key = "AGE-SECRET-KEY-1TEST";
unsafe { env::set_var("MURK_KEY", key) };
let result = resolve_key();
unsafe { env::remove_var("MURK_KEY") };
resolve_key_sandbox_teardown(&tmp, &prev);
let secret = result.unwrap();
use age::secrecy::ExposeSecret;
assert_eq!(secret.expose_secret(), key);
}
#[test]
fn resolve_key_from_file() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("from_file");
unsafe { env::remove_var("MURK_KEY") };
let path = std::env::temp_dir().join("murk_test_key_file");
{
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&path)
.unwrap();
std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
}
#[cfg(not(unix))]
std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
}
unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
let result = resolve_key();
unsafe { env::remove_var("MURK_KEY_FILE") };
std::fs::remove_file(&path).ok();
resolve_key_sandbox_teardown(&tmp, &prev);
let secret = result.unwrap();
use age::secrecy::ExposeSecret;
assert_eq!(secret.expose_secret(), "AGE-SECRET-KEY-1FROMFILE");
}
#[test]
fn resolve_key_file_not_found() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("file_not_found");
unsafe { env::remove_var("MURK_KEY") };
unsafe { env::set_var("MURK_KEY_FILE", "/nonexistent/path/murk_key") };
let result = resolve_key();
unsafe { env::remove_var("MURK_KEY_FILE") };
resolve_key_sandbox_teardown(&tmp, &prev);
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot read"));
}
#[test]
fn resolve_key_neither_set() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("neither_set");
unsafe { env::remove_var("MURK_KEY") };
unsafe { env::remove_var("MURK_KEY_FILE") };
let result = resolve_key();
resolve_key_sandbox_teardown(&tmp, &prev);
assert!(result.is_err());
assert!(result.unwrap_err().contains("MURK_KEY not set"));
}
#[test]
fn resolve_key_empty_string_treated_as_unset() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("empty_string");
unsafe { env::set_var("MURK_KEY", "") };
unsafe { env::remove_var("MURK_KEY_FILE") };
let result = resolve_key();
unsafe { env::remove_var("MURK_KEY") };
resolve_key_sandbox_teardown(&tmp, &prev);
assert!(result.is_err());
assert!(result.unwrap_err().contains("MURK_KEY not set"));
}
#[test]
fn resolve_key_murk_key_takes_priority_over_file() {
let (_env, _cwd, tmp, prev) = resolve_key_sandbox("priority");
let direct_key = "AGE-SECRET-KEY-1DIRECT";
let file_key = "AGE-SECRET-KEY-1FILE";
let path = std::env::temp_dir().join("murk_test_key_priority");
std::fs::write(&path, format!("{file_key}\n")).unwrap();
unsafe { env::set_var("MURK_KEY", direct_key) };
unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
let result = resolve_key();
unsafe { env::remove_var("MURK_KEY") };
unsafe { env::remove_var("MURK_KEY_FILE") };
std::fs::remove_file(&path).ok();
resolve_key_sandbox_teardown(&tmp, &prev);
let secret = result.unwrap();
use age::secrecy::ExposeSecret;
assert_eq!(secret.expose_secret(), direct_key);
}
#[cfg(unix)]
#[test]
fn warn_env_permissions_no_warning_on_secure_file() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("murk_test_perms");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let env_path = dir.join(".env");
std::fs::write(&env_path, "KEY=val\n").unwrap();
std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
warn_env_permissions();
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_key_does_not_read_dotenv() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".env"),
"MURK_KEY=AGE-SECRET-KEY-1SHOULDNEVERBEREAD\n",
)
.unwrap();
let prev_key = env::var(ENV_MURK_KEY).ok();
let prev_keyfile = env::var(ENV_MURK_KEY_FILE).ok();
unsafe {
env::remove_var(ENV_MURK_KEY);
env::remove_var(ENV_MURK_KEY_FILE);
}
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let result = resolve_key_with_source("nonexistent-vault-for-test.murk");
std::env::set_current_dir(original_dir).unwrap();
unsafe {
if let Some(v) = prev_key {
env::set_var(ENV_MURK_KEY, v);
}
if let Some(v) = prev_keyfile {
env::set_var(ENV_MURK_KEY_FILE, v);
}
}
assert!(
result.is_err(),
"resolve_key_with_source must not fall back to .env"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn dotenv_has_murk_key_true() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_has_key_true");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".env"), "MURK_KEY=test\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
assert!(dotenv_has_murk_key());
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn dotenv_has_murk_key_false() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_has_key_false");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".env"), "OTHER=val\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
assert!(!dotenv_has_murk_key());
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn dotenv_has_murk_key_no_file() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_has_key_nofile");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
assert!(!dotenv_has_murk_key());
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_key_to_dotenv_creates_new() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_write_key_new");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
write_key_to_dotenv("AGE-SECRET-KEY-1NEW").unwrap();
let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1NEW"));
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_key_to_dotenv_replaces_existing() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_write_key_replace");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".env"),
"OTHER=keep\nMURK_KEY=old\nexport MURK_KEY=also_old\n",
)
.unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
write_key_to_dotenv("AGE-SECRET-KEY-1REPLACED").unwrap();
let contents = std::fs::read_to_string(dir.join(".env")).unwrap();
assert!(contents.contains("OTHER=keep"));
assert!(contents.contains("export MURK_KEY=AGE-SECRET-KEY-1REPLACED"));
assert!(!contents.contains("MURK_KEY=old"));
assert!(!contents.contains("also_old"));
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn write_key_to_dotenv_permissions_are_600() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("murk_test_write_key_perms");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST").unwrap();
let meta = std::fs::metadata(dir.join(".env")).unwrap();
assert_eq!(
meta.permissions().mode() & 0o777,
SECRET_FILE_MODE,
"new .env should be created with mode 600"
);
write_key_to_dotenv("AGE-SECRET-KEY-1PERMTEST2").unwrap();
let meta = std::fs::metadata(dir.join(".env")).unwrap();
assert_eq!(
meta.permissions().mode() & 0o777,
SECRET_FILE_MODE,
"rewritten .env should maintain mode 600"
);
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_envrc_creates_new() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_envrc_new");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let status = write_envrc(".murk").unwrap();
assert_eq!(status, EnvrcStatus::Created);
let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
assert!(contents.contains("murk export --vault .murk"));
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_envrc_appends() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_envrc_append");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".envrc"), "existing content\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let status = write_envrc(".murk").unwrap();
assert_eq!(status, EnvrcStatus::Appended);
let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
assert!(contents.contains("existing content"));
assert!(contents.contains("murk export"));
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_envrc_already_present() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_envrc_present");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".envrc"),
"eval \"$(murk export --vault .murk)\"\n",
)
.unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let status = write_envrc(".murk").unwrap();
assert_eq!(status, EnvrcStatus::AlreadyPresent);
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn reject_symlink_ok_for_regular_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("regular.txt");
std::fs::write(&path, "content").unwrap();
assert!(reject_symlink(&path, "test").is_ok());
}
#[test]
fn reject_symlink_ok_for_nonexistent() {
let path = std::path::Path::new("/tmp/does_not_exist_murk_test");
assert!(reject_symlink(path, "test").is_ok());
}
#[cfg(unix)]
#[test]
fn reject_symlink_rejects_symlink() {
let dir = tempfile::TempDir::new().unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink("/tmp/target", &link).unwrap();
let result = reject_symlink(&link, "test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("symlink"));
}
#[cfg(unix)]
#[test]
fn read_secret_file_rejects_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("loose.key");
std::fs::write(&path, "secret").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let result = read_secret_file(&path, "test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("readable by others"));
}
#[cfg(unix)]
#[test]
fn read_secret_file_accepts_600() {
use std::os::unix::fs::OpenOptionsExt;
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("tight.key");
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.mode(0o600)
.open(&path)
.unwrap();
std::io::Write::write_all(&mut f, b"secret").unwrap();
let result = read_secret_file(&path, "test");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "secret");
}
#[test]
fn shell_escape_bare_identifiers() {
assert_eq!(shell_escape(".murk"), ".murk");
assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
assert_eq!(
shell_escape("/home/user/.config/murk/key"),
"/home/user/.config/murk/key"
);
}
#[test]
fn shell_escape_quotes_special_chars() {
assert_eq!(shell_escape("my vault"), "'my vault'");
assert_eq!(shell_escape("it's"), "'it'\\''s'");
assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
}
#[test]
fn write_envrc_escapes_vault_name() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_envrc_escape");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let status = write_envrc("my vault.murk").unwrap();
assert_eq!(status, EnvrcStatus::Created);
let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
assert!(contents.contains("'my vault.murk'"));
std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
}