use crate::error::{FnoxError, Result};
use crate::providers::ProviderCapability;
use async_trait::async_trait;
use tokio::process::Command;
pub struct PasswordStoreProvider {
prefix: Option<String>,
store_dir: Option<String>,
gpg_opts: Option<String>,
}
impl PasswordStoreProvider {
pub fn new(
prefix: Option<String>,
store_dir: Option<String>,
gpg_opts: Option<String>,
) -> Result<Self> {
Ok(Self {
prefix,
store_dir,
gpg_opts,
})
}
fn build_secret_path(&self, key: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{prefix}{key}"),
None => key.to_string(),
}
}
fn configure_command_env(&self, cmd: &mut tokio::process::Command) {
let env_store_dir = password_store_dir();
let store_dir = self.store_dir.as_deref().or(env_store_dir.as_deref());
if let Some(store_dir) = store_dir {
cmd.env("PASSWORD_STORE_DIR", store_dir);
}
let env_gpg_opts = password_store_gpg_opts();
let gpg_opts = self.gpg_opts.as_deref().or(env_gpg_opts.as_deref());
if let Some(gpg_opts) = gpg_opts {
cmd.env("PASSWORD_STORE_GPG_OPTS", gpg_opts);
}
}
async fn execute_pass_command(&self, args: &[&str]) -> Result<String> {
tracing::debug!("Executing pass command with args: {args:?}");
let mut cmd = Command::new("pass");
self.configure_command_env(&mut cmd);
cmd.args(args);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let output = cmd.output().await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FnoxError::ProviderCliNotFound {
provider: "password-store".to_string(),
cli: "pass".to_string(),
install_hint: "brew install pass".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
}
} else {
FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: e.to_string(),
hint: "Check that password-store is installed and accessible".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
}
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_str = stderr.trim();
if stderr_str.contains("not in the password store") {
return Err(FnoxError::ProviderSecretNotFound {
provider: "password-store".to_string(),
secret: args.last().copied().unwrap_or("<unspecified>").to_string(),
hint: "Check that the secret exists in your password store".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
});
}
if stderr_str.contains("gpg") || stderr_str.contains("decrypt") {
return Err(FnoxError::ProviderAuthFailed {
provider: "password-store".to_string(),
details: stderr_str.to_string(),
hint: "Check that your GPG key is available and unlocked".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
});
}
return Err(FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: stderr_str.to_string(),
hint: "Check your password-store configuration".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
});
}
let stdout =
String::from_utf8(output.stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "password-store".to_string(),
details: format!("Invalid UTF-8 in command output: {}", e),
hint: "The secret value contains invalid UTF-8 characters".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
})?;
Ok(stdout.trim().to_string())
}
}
#[async_trait]
impl crate::providers::Provider for PasswordStoreProvider {
fn capabilities(&self) -> Vec<ProviderCapability> {
vec![ProviderCapability::RemoteStorage]
}
async fn get_secret(&self, value: &str) -> Result<String> {
let secret_path = self.build_secret_path(value);
tracing::debug!("Getting secret '{secret_path}' from password-store");
self.execute_pass_command(&["show", &secret_path]).await
}
async fn put_secret(&self, key: &str, value: &str) -> Result<String> {
let secret_path = self.build_secret_path(key);
tracing::debug!("Storing secret '{secret_path}' in password-store");
let mut cmd = Command::new("pass");
self.configure_command_env(&mut cmd);
cmd.arg("insert")
.arg("-m") .arg("-f") .arg(&secret_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FnoxError::ProviderCliNotFound {
provider: "password-store".to_string(),
cli: "pass".to_string(),
install_hint: "brew install pass".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
}
} else {
FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: format!("Failed to spawn 'pass insert': {}", e),
hint: "Check that password-store is installed and accessible".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
}
}
})?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(value.as_bytes())
.await
.map_err(|e| FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: format!("Failed to write to stdin: {}", e),
hint: "This is an internal error".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
})?;
drop(stdin); }
let output = child
.wait_with_output()
.await
.map_err(|e| FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: format!("Failed to wait for command: {}", e),
hint: "This is an internal error".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_str = stderr.trim();
if stderr_str.contains("gpg") || stderr_str.contains("encrypt") {
return Err(FnoxError::ProviderAuthFailed {
provider: "password-store".to_string(),
details: stderr_str.to_string(),
hint: "Check that your GPG key is available".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
});
}
return Err(FnoxError::ProviderCliFailed {
provider: "password-store".to_string(),
details: stderr_str.to_string(),
hint: "Check your password-store configuration".to_string(),
url: "https://fnox.jdx.dev/providers/password-store".to_string(),
});
}
tracing::debug!("Successfully stored secret '{secret_path}' in password-store");
Ok(key.to_string())
}
async fn test_connection(&self) -> Result<()> {
tracing::debug!("Testing connection to password-store");
self.execute_pass_command(&["ls"]).await?;
tracing::debug!("password-store connection test successful");
Ok(())
}
}
pub fn env_dependencies() -> &'static [&'static str] {
&[
"PASSWORD_STORE_DIR",
"FNOX_PASSWORD_STORE_DIR",
"PASSWORD_STORE_GPG_OPTS",
"FNOX_PASSWORD_STORE_GPG_OPTS",
]
}
fn password_store_dir() -> Option<String> {
std::env::var("FNOX_PASSWORD_STORE_DIR")
.or_else(|_| std::env::var("PASSWORD_STORE_DIR"))
.ok()
}
fn password_store_gpg_opts() -> Option<String> {
std::env::var("FNOX_PASSWORD_STORE_GPG_OPTS")
.or_else(|_| std::env::var("PASSWORD_STORE_GPG_OPTS"))
.ok()
}