use std::collections::{BTreeMap, HashMap};
use std::env;
use std::process::Command;
use crate::{crypto, encrypt_value, now_utc, types};
fn sanitize_remote_url(url: &str) -> String {
if let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
{
let scheme = if url.starts_with("https://") {
"https"
} else {
"http"
};
if let Some(at_pos) = rest.find('@') {
let slash_pos = rest.find('/').unwrap_or(rest.len());
if at_pos < slash_pos {
return format!("{scheme}://{}", &rest[at_pos + 1..]);
}
}
url.to_string()
} else {
url.to_string()
}
}
#[derive(Debug)]
pub struct DiscoveredKey {
pub secret_key: String,
pub pubkey: String,
}
pub fn discover_existing_key() -> Result<Option<DiscoveredKey>, String> {
let raw = if let Some(k) = env::var(crate::env::ENV_MURK_KEY)
.ok()
.filter(|k| !k.is_empty())
{
Some(k)
} else if let Ok(path) = env::var(crate::env::ENV_MURK_KEY_FILE) {
let p = std::path::Path::new(&path);
crate::env::reject_symlink(p, "MURK_KEY_FILE")?;
Some(
std::fs::read_to_string(p)
.map_err(|e| format!("cannot read MURK_KEY_FILE: {e}"))?
.trim()
.to_string(),
)
} else {
None
};
match raw {
Some(key) => {
let identity = crypto::parse_identity(&key).map_err(|e| e.to_string())?;
let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
Ok(Some(DiscoveredKey {
secret_key: key,
pubkey,
}))
}
None => Ok(None),
}
}
#[derive(Debug)]
pub struct InitStatus {
pub authorized: bool,
pub pubkey: String,
pub display_name: Option<String>,
}
pub fn check_init_status(vault: &types::Vault, secret_key: &str) -> Result<InitStatus, String> {
let identity = crypto::parse_identity(secret_key).map_err(|e| e.to_string())?;
let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
let authorized = vault.recipients.contains(&pubkey);
let display_name = if authorized {
crate::decrypt_meta(vault, &identity)
.and_then(|meta| meta.recipients.get(&pubkey).cloned())
.filter(|name| !name.is_empty())
} else {
None
};
Ok(InitStatus {
authorized,
pubkey,
display_name,
})
}
pub fn create_vault(
vault_name: &str,
pubkey: &str,
name: &str,
) -> Result<types::Vault, crate::error::MurkError> {
use crate::error::MurkError;
let mut recipient_names = HashMap::new();
recipient_names.insert(pubkey.to_string(), name.to_string());
let recipient = crypto::parse_recipient(pubkey)?;
let repo = Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| sanitize_remote_url(s.trim()))
.unwrap_or_default();
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: now_utc(),
vault_name: vault_name.into(),
repo,
recipients: vec![pubkey.to_string()],
schema: BTreeMap::new(),
secrets: BTreeMap::new(),
meta: String::new(),
};
let mac_key_hex = crate::generate_mac_key();
let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
let mac = crate::compute_mac(&vault, Some(&mac_key));
let meta = types::Meta {
recipients: recipient_names,
mac,
mac_key: Some(mac_key_hex),
github_pins: HashMap::new(),
};
let meta_json =
serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
vault.meta = encrypt_value(&meta_json, &[recipient])?;
Ok(vault)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::testutil::{CWD_LOCK, ENV_LOCK};
#[test]
fn discover_existing_key_from_env() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (secret, pubkey) = generate_keypair();
unsafe { env::set_var("MURK_KEY", &secret) };
let result = discover_existing_key();
unsafe { env::remove_var("MURK_KEY") };
let dk = result.unwrap().unwrap();
assert_eq!(dk.secret_key, secret);
assert_eq!(dk.pubkey, pubkey);
}
#[test]
fn discover_existing_key_ignores_dotenv() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
env::remove_var("MURK_KEY");
env::remove_var("MURK_KEY_FILE");
}
let dir = std::env::temp_dir().join("murk_test_discover_ignores_dotenv");
std::fs::create_dir_all(&dir).unwrap();
let (secret, _pubkey) = generate_keypair();
std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
let orig_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let result = discover_existing_key();
std::env::set_current_dir(&orig_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(
result.unwrap().is_none(),
"discover_existing_key must not fall back to .env"
);
}
#[test]
fn discover_existing_key_from_env_file_var() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
env::remove_var("MURK_KEY");
}
let (secret, pubkey) = generate_keypair();
let dir = std::env::temp_dir().join("murk_test_discover_env_file");
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("key");
std::fs::write(&key_path, format!("{secret}\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
unsafe { env::set_var("MURK_KEY_FILE", &key_path) };
let result = discover_existing_key();
unsafe { env::remove_var("MURK_KEY_FILE") };
std::fs::remove_dir_all(&dir).unwrap();
let dk = result.unwrap().unwrap();
assert_eq!(dk.secret_key, secret);
assert_eq!(dk.pubkey, pubkey);
}
#[test]
fn discover_existing_key_neither_set() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { env::remove_var("MURK_KEY") };
let dir = std::env::temp_dir().join("murk_test_discover_none");
std::fs::create_dir_all(&dir).unwrap();
let orig_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let result = discover_existing_key();
std::env::set_current_dir(&orig_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(result.unwrap().is_none());
}
#[test]
fn discover_existing_key_invalid_key() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
let result = discover_existing_key();
unsafe { env::remove_var("MURK_KEY") };
assert!(result.is_err());
}
#[test]
fn check_init_status_authorized() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let mut names = HashMap::new();
names.insert(pubkey.clone(), "Alice".to_string());
let meta = types::Meta {
recipients: names,
mac: String::new(),
mac_key: None,
github_pins: HashMap::new(),
};
let meta_json = serde_json::to_vec(&meta).unwrap();
let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
let vault = types::Vault {
version: "2.0".into(),
created: "2026-01-01T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: std::collections::BTreeMap::new(),
secrets: std::collections::BTreeMap::new(),
meta: meta_enc,
};
let status = check_init_status(&vault, &secret).unwrap();
assert!(status.authorized);
assert_eq!(status.pubkey, pubkey);
assert_eq!(status.display_name.as_deref(), Some("Alice"));
}
#[test]
fn check_init_status_not_authorized() {
let (secret, pubkey) = generate_keypair();
let (_, other_pubkey) = generate_keypair();
let vault = types::Vault {
version: "2.0".into(),
created: "2026-01-01T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![other_pubkey],
schema: std::collections::BTreeMap::new(),
secrets: std::collections::BTreeMap::new(),
meta: String::new(),
};
let status = check_init_status(&vault, &secret).unwrap();
assert!(!status.authorized);
assert_eq!(status.pubkey, pubkey);
assert!(status.display_name.is_none());
}
#[test]
fn create_vault_basic() {
let (_, pubkey) = generate_keypair();
let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
assert_eq!(vault.version, types::VAULT_VERSION);
assert_eq!(vault.vault_name, ".murk");
assert_eq!(vault.recipients, vec![pubkey]);
assert!(vault.schema.is_empty());
assert!(vault.secrets.is_empty());
assert!(!vault.meta.is_empty());
}
#[test]
fn sanitize_strips_https_credentials() {
assert_eq!(
sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn sanitize_strips_https_token() {
assert_eq!(
sanitize_remote_url("https://ghp_abc123@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn sanitize_preserves_clean_https() {
assert_eq!(
sanitize_remote_url("https://github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn sanitize_preserves_ssh() {
assert_eq!(
sanitize_remote_url("git@github.com:org/repo.git"),
"git@github.com:org/repo.git"
);
}
#[test]
fn sanitize_strips_http_credentials() {
assert_eq!(
sanitize_remote_url("http://user:pass@example.com/repo"),
"http://example.com/repo"
);
}
}