use std::io::{
Read,
Write,
};
use std::path::{
Path,
PathBuf,
};
use age::secrecy::ExposeSecret;
use age::x25519;
use base64::Engine as _;
use crate::error::{
SecretsError,
SecretsResult,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyStoreTarget {
OsKeychain,
SystemStore,
File,
OsKeychainAndFile,
}
#[derive(Debug, Clone)]
pub enum KeyLocation {
OsKeychain {
service: String,
account: String,
},
SystemKeychain {
service: String,
account: String,
},
SystemFile(PathBuf),
UserFile(PathBuf),
}
#[derive(Debug, Clone)]
pub struct KeyGenOptions {
pub target: KeyStoreTarget,
pub key_name: Option<String>,
pub file_path: Option<PathBuf>,
pub force: bool,
}
pub struct KeyGenResult {
pub manager: SecretManager,
pub locations: Vec<KeyLocation>,
pub public_key: String,
}
impl std::fmt::Debug for KeyGenResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyGenResult")
.field("locations", &self.locations)
.field("public_key", &self.public_key)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct SecretManager {
identity: x25519::Identity,
}
trait KeyBackend {
fn load_identity_string(&self) -> SecretsResult<Option<String>>;
fn save_identity_string(&self, _identity: &str) -> SecretsResult<()> {
Err(SecretsError::KeySaveFailed(
"save operation not implemented for this backend".to_string(),
))
}
}
struct FileKeyBackend {
path: PathBuf,
}
impl FileKeyBackend {
fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl KeyBackend for FileKeyBackend {
fn load_identity_string(&self) -> SecretsResult<Option<String>> {
if !self.path.exists() {
return Ok(None);
}
let key_data = std::fs::read_to_string(&self.path).map_err(|e| {
SecretsError::KeyLoadFailed(format!("read {}: {}", self.path.display(), e))
})?;
Ok(Some(key_data))
}
fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
})?;
}
std::fs::write(&self.path, identity.as_bytes()).map_err(|e| {
SecretsError::KeySaveFailed(format!("write {}: {}", self.path.display(), e))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&self.path)
.map_err(|e| {
SecretsError::KeySaveFailed(format!("metadata {}: {}", self.path.display(), e))
})?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&self.path, perms).map_err(|e| {
SecretsError::KeySaveFailed(format!("chmod {}: {}", self.path.display(), e))
})?;
}
Ok(())
}
}
struct OsKeychainBackend {
service: String,
account: String,
}
impl OsKeychainBackend {
fn new(service: String, account: String) -> Self {
Self { service, account }
}
}
impl KeyBackend for OsKeychainBackend {
fn load_identity_string(&self) -> SecretsResult<Option<String>> {
load_from_os_keychain(&self.service, &self.account)
}
fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
save_to_os_keychain(&self.service, &self.account, identity)
}
}
fn normalize_key_data(data: &str) -> Option<String> {
let trimmed = data.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
#[cfg(feature = "os-keychain")]
fn load_from_os_keychain(service: &str, account: &str) -> SecretsResult<Option<String>> {
let entry = match keyring::Entry::new(service, account) {
Ok(e) => e,
Err(_) => return Ok(None),
};
match entry.get_password() {
Ok(password) => Ok(normalize_key_data(&password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(keyring::Error::PlatformFailure(_)) => Ok(None),
Err(e) => Err(SecretsError::KeyLoadFailed(format!(
"OS keychain read failed (service='{}', account='{}'): {}",
service, account, e
))),
}
}
#[cfg(not(feature = "os-keychain"))]
fn load_from_os_keychain(_service: &str, _account: &str) -> SecretsResult<Option<String>> {
Ok(None)
}
#[cfg(feature = "os-keychain")]
fn save_to_os_keychain(service: &str, account: &str, identity: &str) -> SecretsResult<()> {
let entry = keyring::Entry::new(service, account).map_err(|e| {
SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
})?;
entry.set_password(identity).map_err(|e| {
SecretsError::KeySaveFailed(format!(
"failed to save to OS keychain (service='{}', account='{}'): {}",
service, account, e
))
})
}
#[cfg(not(feature = "os-keychain"))]
fn save_to_os_keychain(_service: &str, _account: &str, _identity: &str) -> SecretsResult<()> {
Err(SecretsError::KeySaveFailed(
"OS keychain support not compiled (enable 'os-keychain' feature)".to_string(),
))
}
#[cfg(feature = "os-keychain")]
fn delete_from_os_keychain(service: &str, account: &str) -> SecretsResult<()> {
let entry = keyring::Entry::new(service, account).map_err(|e| {
SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
})?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(SecretsError::KeySaveFailed(format!(
"failed to delete from OS keychain (service='{}', account='{}'): {}",
service, account, e
))),
}
}
struct SystemStoreBackend {
key_name: String,
}
impl SystemStoreBackend {
fn new(key_name: String) -> Self {
Self { key_name }
}
#[allow(dead_code)]
fn path(&self) -> PathBuf {
system_store_path_for(&self.key_name)
}
}
impl KeyBackend for SystemStoreBackend {
fn load_identity_string(&self) -> SecretsResult<Option<String>> {
load_from_system_store_impl(&self.key_name)
}
fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
save_to_system_store_impl(&self.key_name, identity)
}
}
fn system_store_path_for(_key_name: &str) -> PathBuf {
if let Ok(dir) = std::env::var("DOTENVAGE_SYSTEM_STORE_DIR")
&& !dir.is_empty()
{
return PathBuf::from(dir).join(format!("{}.key", _key_name));
}
#[cfg(unix)]
{
PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
}
#[cfg(target_os = "windows")]
{
let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string());
PathBuf::from(base)
.join("dotenvage")
.join(format!("{}.key", _key_name))
}
#[cfg(not(any(unix, target_os = "windows")))]
{
PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
}
}
fn load_from_system_store_impl(key_name: &str) -> SecretsResult<Option<String>> {
#[cfg(target_os = "macos")]
if let Some(data) = load_from_macos_system_keychain(key_name)? {
return Ok(Some(data));
}
let path = system_store_path_for(key_name);
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&path)
.map_err(|e| SecretsError::KeyLoadFailed(format!("read {}: {}", path.display(), e)))?;
Ok(normalize_key_data(&data))
}
fn save_to_system_store_impl(key_name: &str, identity: &str) -> SecretsResult<()> {
#[cfg(target_os = "macos")]
{
save_to_macos_system_keychain(key_name, identity)
}
#[cfg(not(target_os = "macos"))]
{
let path = system_store_path_for(key_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
return SecretsError::InsufficientPrivileges(format!(
"cannot create {}: {} (try with sudo/admin)",
parent.display(),
e
));
}
SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
})?;
}
std::fs::write(&path, identity.as_bytes()).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
return SecretsError::InsufficientPrivileges(format!(
"cannot write {}: {} (try with sudo/admin)",
path.display(),
e
));
}
SecretsError::KeySaveFailed(format!("write {}: {}", path.display(), e))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)
.map_err(|e| {
SecretsError::KeySaveFailed(format!("metadata {}: {}", path.display(), e))
})?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms).map_err(|e| {
SecretsError::KeySaveFailed(format!("chmod {}: {}", path.display(), e))
})?;
}
Ok(())
}
}
fn resolve_user_home(username: &str) -> SecretsResult<PathBuf> {
#[cfg(unix)]
{
use nix::unistd::User;
let user = User::from_name(username).map_err(|e| {
SecretsError::KeyLoadFailed(format!("failed to look up user '{}': {}", username, e))
})?;
match user {
Some(u) => Ok(u.dir),
None => Err(SecretsError::KeyLoadFailed(format!(
"user '{}' not found",
username
))),
}
}
#[cfg(windows)]
{
let drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string());
Ok(PathBuf::from(drive).join("Users").join(username))
}
#[cfg(not(any(unix, windows)))]
{
let _ = username;
Err(SecretsError::KeyLoadFailed(
"resolve_user_home not supported on this platform".to_string(),
))
}
}
#[cfg(target_os = "macos")]
fn load_from_macos_system_keychain(key_name: &str) -> SecretsResult<Option<String>> {
use security_framework::os::macos::keychain::SecKeychain;
let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
.map_err(|e| SecretsError::KeyLoadFailed(format!("cannot open System Keychain: {}", e)))?;
let service = SecretManager::keychain_service_name();
match keychain.find_generic_password(&service, key_name) {
Ok((password, _item)) => {
let data = String::from_utf8(password.as_ref().to_vec()).map_err(|e| {
SecretsError::KeyLoadFailed(format!("invalid keychain data: {}", e))
})?;
Ok(normalize_key_data(&data))
}
Err(e) if e.code() == -25300 => Ok(None),
Err(_) => Ok(None), }
}
#[cfg(target_os = "macos")]
fn save_to_macos_system_keychain(key_name: &str, identity: &str) -> SecretsResult<()> {
use security_framework::os::macos::keychain::SecKeychain;
let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
.map_err(|e| SecretsError::KeySaveFailed(format!("cannot open System Keychain: {e}")))?;
let service = SecretManager::keychain_service_name();
keychain
.set_generic_password(&service, key_name, identity.as_bytes())
.map_err(|e| {
let msg = e.to_string();
if msg.contains("Authorization") || msg.contains("permission") || e.code() == -25293 {
return SecretsError::InsufficientPrivileges(format!(
"cannot write to System Keychain \
(try with sudo): {msg}"
));
}
SecretsError::KeySaveFailed(format!(
"failed to save to macOS System Keychain \
(service='{service}', account='{key_name}'): {msg}"
))
})
}
struct DotenvageVars {
age_key_name: Option<String>,
system_store_dir: Option<String>,
}
impl SecretManager {
pub fn new() -> SecretsResult<Self> {
Self::load_key()
}
pub fn generate() -> SecretsResult<Self> {
Ok(Self {
identity: x25519::Identity::generate(),
})
}
pub fn from_identity(identity: x25519::Identity) -> Self {
Self { identity }
}
pub fn public_key(&self) -> x25519::Recipient {
self.identity.to_public()
}
pub fn public_key_string(&self) -> String {
self.public_key().to_string()
}
pub fn encrypt_value(&self, plaintext: &str) -> SecretsResult<String> {
let recipient = self.public_key();
let recipients: Vec<&dyn age::Recipient> = vec![&recipient];
let encryptor = age::Encryptor::with_recipients(recipients.into_iter())
.map_err(|e: age::EncryptError| SecretsError::EncryptionFailed(e.to_string()))?;
let mut encrypted = Vec::new();
let mut writer = encryptor
.wrap_output(&mut encrypted)
.map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
writer
.write_all(plaintext.as_bytes())
.map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
writer
.finish()
.map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
Ok(format!("ENC[AGE:b64:{}]", b64))
}
pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
let trimmed = value.trim();
if let Some(inner) = trimmed
.strip_prefix("ENC[AGE:b64:")
.and_then(|s| s.strip_suffix(']'))
{
let encrypted = base64::engine::general_purpose::STANDARD
.decode(inner)
.map_err(|e| SecretsError::DecryptionFailed(format!("invalid base64: {}", e)))?;
let decryptor = age::Decryptor::new(&encrypted[..])
.map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
let identities: Vec<&dyn age::Identity> = vec![&self.identity];
let mut reader = decryptor
.decrypt(identities.into_iter())
.map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
let mut decrypted = Vec::new();
reader
.read_to_end(&mut decrypted)
.map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
return String::from_utf8(decrypted)
.map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
}
if trimmed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
let armor_reader = age::armor::ArmoredReader::new(trimmed.as_bytes());
let decryptor = age::Decryptor::new(armor_reader)
.map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
let identities: Vec<&dyn age::Identity> = vec![&self.identity];
let mut reader = decryptor
.decrypt(identities.into_iter())
.map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
let mut decrypted = Vec::new();
reader
.read_to_end(&mut decrypted)
.map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
return String::from_utf8(decrypted)
.map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
}
Ok(value.to_string())
}
pub fn is_encrypted(value: &str) -> bool {
let t = value.trim();
t.starts_with("ENC[AGE:b64:") || t.starts_with("-----BEGIN AGE ENCRYPTED FILE-----")
}
pub fn save_key(&self, path: impl AsRef<Path>) -> SecretsResult<()> {
let backend = FileKeyBackend::new(path.as_ref().to_path_buf());
backend.save_identity_string(&self.identity_string())
}
pub fn save_key_to_default(&self) -> SecretsResult<PathBuf> {
let p = Self::default_key_path();
self.save_key(&p)?;
Ok(p)
}
pub fn save_key_to_os_keychain(&self) -> SecretsResult<(String, String)> {
let service = Self::keychain_service_name();
let account = Self::key_name_from_env_or_default();
let backend = OsKeychainBackend::new(service.clone(), account.clone());
backend.save_identity_string(&self.identity_string())?;
Ok((service, account))
}
pub fn save_key_to_system_store(&self) -> SecretsResult<KeyLocation> {
let key_name = Self::key_name_from_env_or_default();
self.save_key_to_system_store_as(&key_name)
}
pub fn save_key_to_system_store_as(&self, key_name: &str) -> SecretsResult<KeyLocation> {
let backend = SystemStoreBackend::new(key_name.to_string());
backend.save_identity_string(&self.identity_string())?;
#[cfg(target_os = "macos")]
{
let service = Self::keychain_service_name();
Ok(KeyLocation::SystemKeychain {
service,
account: key_name.to_string(),
})
}
#[cfg(not(target_os = "macos"))]
{
Ok(KeyLocation::SystemFile(backend.path()))
}
}
pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
if let Some(ref name) = options.key_name {
unsafe {
std::env::set_var("AGE_KEY_NAME", name);
}
} else {
Self::discover_age_key_name_from_env_files()?;
}
let manager = Self::generate()?;
let mut locations = Vec::new();
match options.target {
KeyStoreTarget::File => {
let path = options
.file_path
.unwrap_or_else(Self::key_path_from_env_or_default);
if path.exists() && !options.force {
return Err(SecretsError::KeyAlreadyExists(format!(
"key file at {}",
path.display()
)));
}
manager.save_key(&path)?;
locations.push(KeyLocation::UserFile(path));
}
KeyStoreTarget::OsKeychain => {
let (service, account) = manager.save_key_to_os_keychain()?;
locations.push(KeyLocation::OsKeychain { service, account });
}
KeyStoreTarget::SystemStore => {
let key_name = Self::key_name_from_env_or_default();
let loc = manager.save_key_to_system_store_as(&key_name)?;
locations.push(loc);
}
KeyStoreTarget::OsKeychainAndFile => {
let (service, account) = manager.save_key_to_os_keychain()?;
locations.push(KeyLocation::OsKeychain { service, account });
let path = options
.file_path
.unwrap_or_else(Self::key_path_from_env_or_default);
if path.exists() && !options.force {
return Err(SecretsError::KeyAlreadyExists(format!(
"key file at {}",
path.display()
)));
}
manager.save_key(&path)?;
locations.push(KeyLocation::UserFile(path));
}
}
let public_key = manager.public_key_string();
Ok(KeyGenResult {
manager,
locations,
public_key,
})
}
pub fn load_from_system_store() -> SecretsResult<Self> {
Self::discover_age_key_name_from_env_files()?;
let key_name = Self::key_name_from_env_or_default();
let backend = SystemStoreBackend::new(key_name.clone());
match backend.load_identity_string()? {
Some(data) => Self::load_from_string(&data),
None => Err(SecretsError::KeyLoadFailed(format!(
"no key found in system store for '{}'",
key_name
))),
}
}
pub fn load_from_user(username: &str) -> SecretsResult<Self> {
Self::discover_age_key_name_from_env_files()?;
let key_name = Self::key_name_from_env_or_default();
let home = resolve_user_home(username)?;
let key_path = home
.join(".local/state")
.join(&key_name)
.with_extension("key");
let backend = FileKeyBackend::new(key_path.clone());
match backend.load_identity_string()? {
Some(data) => Self::load_from_string(&data),
None => Err(SecretsError::KeyLoadFailed(format!(
"no key file for user '{}' at {}",
username,
key_path.display()
))),
}
}
pub fn key_exists_in_os_keychain() -> bool {
let _ = Self::discover_age_key_name_from_env_files();
let key_name = Self::key_name_from_env_or_default();
let service = Self::keychain_service_name();
let backend = OsKeychainBackend::new(service, key_name);
matches!(backend.load_identity_string(), Ok(Some(_)))
}
pub fn key_exists_in_system_store() -> bool {
let _ = Self::discover_age_key_name_from_env_files();
let key_name = Self::key_name_from_env_or_default();
let backend = SystemStoreBackend::new(key_name);
matches!(backend.load_identity_string(), Ok(Some(_)))
}
#[cfg(feature = "os-keychain")]
pub fn delete_from_os_keychain() -> SecretsResult<()> {
let _ = Self::discover_age_key_name_from_env_files();
let key_name = Self::key_name_from_env_or_default();
let service = Self::keychain_service_name();
delete_from_os_keychain(&service, &key_name)
}
pub fn system_store_path() -> PathBuf {
let _ = Self::discover_age_key_name_from_env_files();
let key_name = Self::key_name_from_env_or_default();
system_store_path_for(&key_name)
}
pub fn load_key() -> SecretsResult<Self> {
Self::discover_age_key_name_from_env_files()?;
if let Ok(data) = std::env::var("DOTENVAGE_AGE_KEY") {
return Self::load_from_string(&data);
}
if let Ok(data) = std::env::var("AGE_KEY") {
return Self::load_from_string(&data);
}
if let Ok(data) = std::env::var("EKG_AGE_KEY") {
return Self::load_from_string(&data);
}
let key_name = Self::key_name_from_env_or_default();
let keychain_service = Self::keychain_service_name();
let os_keychain_backend = OsKeychainBackend::new(keychain_service, key_name.clone());
if let Some(data) = os_keychain_backend.load_identity_string()? {
return Self::load_from_string(&data);
}
let system_backend = SystemStoreBackend::new(key_name);
if let Some(data) = system_backend.load_identity_string()? {
return Self::load_from_string(&data);
}
let key_path = Self::key_path_from_env_or_default();
let file_backend = FileKeyBackend::new(key_path.clone());
if let Some(data) = file_backend.load_identity_string()? {
return Self::load_from_string(&data);
}
Err(SecretsError::KeyLoadFailed(format!(
"no key found (env vars, OS keychain, system store, \
or key file at {})",
key_path.display()
)))
}
pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
let env_files = [".env.local", ".env"];
for env_file in &env_files {
let vars = Self::find_dotenvage_vars_in_file(env_file)?;
if let Some(key_name) = vars.age_key_name
&& std::env::var("AGE_KEY_NAME").is_err()
{
unsafe {
std::env::set_var("AGE_KEY_NAME", key_name);
}
}
if let Some(dir) = vars.system_store_dir
&& std::env::var("DOTENVAGE_SYSTEM_STORE_DIR").is_err()
{
unsafe {
std::env::set_var("DOTENVAGE_SYSTEM_STORE_DIR", dir);
}
}
}
Ok(())
}
fn find_dotenvage_vars_in_file(file_path: &str) -> SecretsResult<DotenvageVars> {
let mut vars = DotenvageVars {
age_key_name: None,
system_store_dir: None,
};
let Ok(content) = std::fs::read_to_string(file_path) else {
return Ok(vars);
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if (key == "AGE_KEY_NAME" || key.ends_with("_AGE_KEY_NAME")) && !value.is_empty() {
if Self::is_encrypted(value) {
return Err(SecretsError::KeyLoadFailed(format!(
"found encrypted AGE key name variable \
'{key}' in {file_path}: AGE key name \
variables must be plaintext because they \
are used to discover the encryption key."
)));
}
vars.age_key_name = Some(value.to_string());
}
if key == "DOTENVAGE_SYSTEM_STORE_DIR" && !value.is_empty() {
vars.system_store_dir = Some(value.to_string());
}
}
Ok(vars)
}
fn load_from_string(data: &str) -> SecretsResult<Self> {
let identity = data
.parse::<x25519::Identity>()
.map_err(|e| SecretsError::KeyLoadFailed(format!("parse key: {}", e)))?;
Ok(Self { identity })
}
pub fn identity_string(&self) -> String {
self.identity.to_string().expose_secret().to_string()
}
fn key_name_from_env_or_default() -> String {
std::env::var("AGE_KEY_NAME")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
})
}
fn keychain_service_name() -> String {
std::env::var("DOTENVAGE_KEYCHAIN_SERVICE")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "dotenvage".to_string())
}
pub fn key_path_from_env_or_default() -> PathBuf {
let key_name = Self::key_name_from_env_or_default();
Self::xdg_base_dir_for(&key_name)
.unwrap_or_else(|| PathBuf::from(".").join(&key_name))
.with_extension("key")
}
pub fn default_key_path() -> PathBuf {
Self::xdg_base_dir_for("dotenvage")
.unwrap_or_else(|| PathBuf::from(".").join("dotenvage"))
.join("dotenvage.key")
}
fn xdg_base_dir_for(name: &str) -> Option<PathBuf> {
if let Ok(p) = std::env::var("XDG_STATE_HOME")
&& !p.is_empty()
{
return Some(PathBuf::from(p).join(name));
}
if let Ok(p) = std::env::var("XDG_CONFIG_HOME")
&& !p.is_empty()
{
return Some(PathBuf::from(p).join(name));
}
if let Ok(home) = std::env::var("HOME") {
let home_path = PathBuf::from(home);
let state_dir = home_path.join(".local/state").join(name);
if state_dir.exists() || !home_path.join(".config").join(name).exists() {
return Some(state_dir);
}
return Some(home_path.join(".config").join(name));
}
None
}
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let manager = SecretManager::generate().expect("failed to generate manager");
let plaintext = "sk_live_abc123";
let encrypted = manager.encrypt_value(plaintext).expect("encryption failed");
assert!(SecretManager::is_encrypted(&encrypted));
let decrypted = manager
.decrypt_value(&encrypted)
.expect("decryption failed");
assert_eq!(plaintext, decrypted);
}
#[test]
fn test_decrypt_unencrypted_value() {
let manager = SecretManager::generate().expect("failed to generate manager");
let plaintext = "not_encrypted";
let result = manager
.decrypt_value(plaintext)
.expect("decrypt should pass through");
assert_eq!(plaintext, result);
}
#[test]
#[serial]
fn test_key_path_from_env_or_default_with_age_key_name() {
let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("AGE_KEY_NAME", "myproject/myapp");
std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
}
let path = SecretManager::key_path_from_env_or_default();
assert_eq!(
path,
std::path::PathBuf::from("/tmp/xdg-state/myproject/myapp.key")
);
unsafe {
std::env::remove_var("AGE_KEY_NAME");
std::env::remove_var("XDG_STATE_HOME");
if let Some(val) = orig_age_key_name {
std::env::set_var("AGE_KEY_NAME", val);
}
if let Some(val) = orig_xdg_state {
std::env::set_var("XDG_STATE_HOME", val);
}
if let Some(val) = orig_xdg_config {
std::env::set_var("XDG_CONFIG_HOME", val);
}
}
}
#[test]
#[serial]
fn test_key_path_from_env_or_default_without_age_key_name() {
let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::remove_var("AGE_KEY_NAME");
std::env::remove_var("XDG_CONFIG_HOME"); std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
}
let path = SecretManager::key_path_from_env_or_default();
let expected = format!("/tmp/xdg-state/{}/dotenvage.key", env!("CARGO_PKG_NAME"));
assert_eq!(path, std::path::PathBuf::from(expected));
unsafe {
std::env::remove_var("XDG_STATE_HOME");
if let Some(val) = orig_age_key_name {
std::env::set_var("AGE_KEY_NAME", val);
}
if let Some(val) = orig_xdg_state {
std::env::set_var("XDG_STATE_HOME", val);
}
if let Some(val) = orig_xdg_config {
std::env::set_var("XDG_CONFIG_HOME", val);
}
}
}
#[test]
#[serial]
fn test_key_name_from_env_or_default() {
let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
unsafe {
std::env::set_var("AGE_KEY_NAME", "myproject/prod");
}
assert_eq!(
SecretManager::key_name_from_env_or_default(),
"myproject/prod"
);
unsafe {
std::env::set_var("AGE_KEY_NAME", " ");
}
assert_eq!(
SecretManager::key_name_from_env_or_default(),
format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
);
unsafe {
if let Some(val) = orig_age_key_name {
std::env::set_var("AGE_KEY_NAME", val);
} else {
std::env::remove_var("AGE_KEY_NAME");
}
}
}
#[test]
#[serial]
fn test_keychain_service_name() {
let orig = std::env::var("DOTENVAGE_KEYCHAIN_SERVICE").ok();
unsafe {
std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "team-secrets");
}
assert_eq!(SecretManager::keychain_service_name(), "team-secrets");
unsafe {
std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", " ");
}
assert_eq!(SecretManager::keychain_service_name(), "dotenvage");
unsafe {
if let Some(val) = orig {
std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", val);
} else {
std::env::remove_var("DOTENVAGE_KEYCHAIN_SERVICE");
}
}
}
#[test]
#[serial]
fn test_xdg_base_dir_for() {
let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
let orig_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("XDG_STATE_HOME", "/custom/state");
}
let path = SecretManager::xdg_base_dir_for("test");
assert_eq!(path, Some(std::path::PathBuf::from("/custom/state/test")));
unsafe {
std::env::remove_var("XDG_STATE_HOME");
std::env::remove_var("XDG_CONFIG_HOME");
std::env::set_var("HOME", "/home/user");
}
let path = SecretManager::xdg_base_dir_for("test");
assert_eq!(
path,
Some(std::path::PathBuf::from("/home/user/.local/state/test"))
);
unsafe {
if let Some(val) = orig_xdg_state {
std::env::set_var("XDG_STATE_HOME", val);
} else {
std::env::remove_var("XDG_STATE_HOME");
}
if let Some(val) = orig_xdg_config {
std::env::set_var("XDG_CONFIG_HOME", val);
} else {
std::env::remove_var("XDG_CONFIG_HOME");
}
if let Some(val) = orig_home {
std::env::set_var("HOME", val);
} else {
std::env::remove_var("HOME");
}
}
}
}