use crate::env;
use crate::error::{FnoxError, Result};
use async_trait::async_trait;
use std::sync::LazyLock;
use tokio::process::Command;
pub fn env_dependencies() -> &'static [&'static str] {
&[
"PROTON_PASS_PASSWORD",
"FNOX_PROTON_PASS_PASSWORD",
"PROTON_PASS_TOTP",
"FNOX_PROTON_PASS_TOTP",
"PROTON_PASS_EXTRA_PASSWORD",
"FNOX_PROTON_PASS_EXTRA_PASSWORD",
"PROTON_PASS_PASSWORD_FILE",
"FNOX_PROTON_PASS_PASSWORD_FILE",
"PROTON_PASS_TOTP_FILE",
"FNOX_PROTON_PASS_TOTP_FILE",
"PROTON_PASS_EXTRA_PASSWORD_FILE",
"FNOX_PROTON_PASS_EXTRA_PASSWORD_FILE",
]
}
pub struct ProtonPassProvider {
vault: Option<String>,
}
impl ProtonPassProvider {
pub fn new(vault: Option<String>) -> Result<Self> {
Ok(Self { vault })
}
fn value_to_reference(&self, value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() {
return Err(FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".to_string(),
details: "Secret reference cannot be empty".to_string(),
hint: "Provide an item name, item/field, vault/item/field, or pass:// reference"
.to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
if let Some(path) = value.strip_prefix("pass://") {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() < 3 || parts.iter().any(|p| p.is_empty()) {
return Err(FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".to_string(),
details: format!("Invalid pass:// reference format: '{}'", value),
hint: "Expected format: pass://vault/item/field".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
return Ok(value.to_string());
}
let parts: Vec<&str> = value.split('/').collect();
match parts.len() {
1 | 2 => {
let vault = self.vault.as_ref().ok_or_else(|| {
FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".to_string(),
details: format!("Unknown vault for secret: '{}'", value),
hint: "Specify a vault in the provider config or use a full 'pass://' reference".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
}
})?;
let field = if parts.len() == 1 {
"password"
} else {
parts[1]
};
Ok(format!("pass://{}/{}/{}", vault, parts[0], field))
}
3 => Ok(format!("pass://{}/{}/{}", parts[0], parts[1], parts[2])),
_ => Err(FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".to_string(),
details: format!("Invalid secret reference format: '{}'", value),
hint: "Expected 'item', 'item/field', 'vault/item/field', or 'pass://vault/item/field'".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
}),
}
}
async fn execute_pass_cli_command(
&self,
args: &[&str],
secret_ref: Option<&str>,
) -> Result<String> {
tracing::debug!("Executing pass-cli command with args: {:?}", args);
let mut cmd = Command::new("pass-cli");
cmd.args(args);
let env_vars_to_pass = [
("PROTON_PASS_PASSWORD", &*PROTON_PASS_PASSWORD),
("PROTON_PASS_PASSWORD_FILE", &*PROTON_PASS_PASSWORD_FILE),
("PROTON_PASS_TOTP", &*PROTON_PASS_TOTP),
("PROTON_PASS_TOTP_FILE", &*PROTON_PASS_TOTP_FILE),
("PROTON_PASS_EXTRA_PASSWORD", &*PROTON_PASS_EXTRA_PASSWORD),
(
"PROTON_PASS_EXTRA_PASSWORD_FILE",
&*PROTON_PASS_EXTRA_PASSWORD_FILE,
),
];
for (name, value) in env_vars_to_pass {
if let Some(v) = value {
cmd.env(name, v);
}
}
let output = cmd.output().await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FnoxError::ProviderCliNotFound {
provider: "Proton Pass".to_string(),
cli: "pass-cli".to_string(),
install_hint:
"Download from https://proton.me/pass/download or use your package manager"
.to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
}
} else {
FnoxError::ProviderCliFailed {
provider: "Proton Pass".to_string(),
details: e.to_string(),
hint: "Check that the Proton Pass CLI (pass-cli) is installed and accessible"
.to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
}
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_lower = stderr.to_lowercase();
if stderr_lower.contains("not logged in")
|| stderr_lower.contains("session expired")
|| stderr_lower.contains("login required")
|| stderr_lower.contains("could not get local key from keyring")
|| stderr_lower.contains("failed to get encryption key")
{
return Err(FnoxError::ProviderAuthFailed {
provider: "Proton Pass".to_string(),
details: stderr.trim().to_string(),
hint: "Run 'pass-cli login --interactive' to authenticate".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
if stderr_lower.contains("field does not exist") {
return Err(FnoxError::ProviderSecretNotFound {
provider: "Proton Pass".to_string(),
secret: secret_ref.unwrap_or("<unknown>").to_string(),
hint: "This item may not have the requested field. Try specifying a different field with 'item/field' syntax".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
if stderr_lower.contains("not found") || stderr_lower.contains("does not exist") {
return Err(FnoxError::ProviderSecretNotFound {
provider: "Proton Pass".to_string(),
secret: secret_ref.unwrap_or("<unknown>").to_string(),
hint: "Check that the vault and item exist in Proton Pass".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
return Err(FnoxError::ProviderCliFailed {
provider: "Proton Pass".to_string(),
details: stderr.trim().to_string(),
hint: "Check your Proton Pass configuration and authentication".to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
});
}
let stdout =
String::from_utf8(output.stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".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/proton-pass".to_string(),
})?;
Ok(stdout.trim().to_string())
}
}
#[async_trait]
impl crate::providers::Provider for ProtonPassProvider {
async fn get_secret(&self, value: &str) -> Result<String> {
tracing::debug!("Getting secret '{}' from Proton Pass", value);
let value = value.trim();
if let Some(id_ref) = value.strip_prefix("id:") {
let vault = self
.vault
.as_ref()
.ok_or_else(|| FnoxError::ProviderInvalidResponse {
provider: "Proton Pass".to_string(),
details: format!("Unknown vault for id-based reference: '{}'", value),
hint: "Specify a vault in the provider config when using id: references"
.to_string(),
url: "https://fnox.jdx.dev/providers/proton-pass".to_string(),
})?;
let (item_id, field) = match id_ref.split_once('/') {
Some((id, f)) => (id, f),
None => (id_ref, "password"),
};
tracing::debug!(
"Reading Proton Pass secret by ID: {} field: {}",
item_id,
field
);
return self
.execute_pass_cli_command(
&[
"item",
"view",
"--vault-name",
vault,
"--item-id",
item_id,
"--field",
field,
],
Some(value),
)
.await;
}
let reference = self.value_to_reference(value)?;
tracing::debug!("Reading Proton Pass secret: {}", reference);
self.execute_pass_cli_command(&["item", "view", &reference], Some(&reference))
.await
}
async fn test_connection(&self) -> Result<()> {
tracing::debug!("Testing connection to Proton Pass");
let output = self.execute_pass_cli_command(&["test"], None).await?;
tracing::debug!("Proton Pass test output: {}", output);
Ok(())
}
}
static PROTON_PASS_PASSWORD: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_PASSWORD")
.or_else(|_| env::var("PROTON_PASS_PASSWORD"))
.ok()
});
static PROTON_PASS_PASSWORD_FILE: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_PASSWORD_FILE")
.or_else(|_| env::var("PROTON_PASS_PASSWORD_FILE"))
.ok()
});
static PROTON_PASS_TOTP: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_TOTP")
.or_else(|_| env::var("PROTON_PASS_TOTP"))
.ok()
});
static PROTON_PASS_TOTP_FILE: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_TOTP_FILE")
.or_else(|_| env::var("PROTON_PASS_TOTP_FILE"))
.ok()
});
static PROTON_PASS_EXTRA_PASSWORD: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_EXTRA_PASSWORD")
.or_else(|_| env::var("PROTON_PASS_EXTRA_PASSWORD"))
.ok()
});
static PROTON_PASS_EXTRA_PASSWORD_FILE: LazyLock<Option<String>> = LazyLock::new(|| {
env::var("FNOX_PROTON_PASS_EXTRA_PASSWORD_FILE")
.or_else(|_| env::var("PROTON_PASS_EXTRA_PASSWORD_FILE"))
.ok()
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_value_to_reference_passthrough() {
let provider = ProtonPassProvider::new(Some("vault".to_string())).unwrap();
let result = provider
.value_to_reference("pass://MyVault/item/password")
.unwrap();
assert_eq!(result, "pass://MyVault/item/password");
}
#[test]
fn test_value_to_reference_single_part_with_vault() {
let provider = ProtonPassProvider::new(Some("TestVault".to_string())).unwrap();
let result = provider.value_to_reference("my-item").unwrap();
assert_eq!(result, "pass://TestVault/my-item/password");
}
#[test]
fn test_value_to_reference_single_part_without_vault() {
let provider = ProtonPassProvider::new(None).unwrap();
let result = provider.value_to_reference("my-item");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_two_parts_with_vault() {
let provider = ProtonPassProvider::new(Some("TestVault".to_string())).unwrap();
let result = provider.value_to_reference("my-item/username").unwrap();
assert_eq!(result, "pass://TestVault/my-item/username");
}
#[test]
fn test_value_to_reference_three_parts() {
let provider = ProtonPassProvider::new(None).unwrap();
let result = provider
.value_to_reference("OtherVault/item/field")
.unwrap();
assert_eq!(result, "pass://OtherVault/item/field");
}
#[test]
fn test_value_to_reference_too_many_parts() {
let provider = ProtonPassProvider::new(Some("vault".to_string())).unwrap();
let result = provider.value_to_reference("a/b/c/d");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_empty() {
let provider = ProtonPassProvider::new(Some("vault".to_string())).unwrap();
let result = provider.value_to_reference("");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_whitespace_only() {
let provider = ProtonPassProvider::new(Some("vault".to_string())).unwrap();
let result = provider.value_to_reference(" ");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_invalid_pass_uri_too_few_parts() {
let provider = ProtonPassProvider::new(None).unwrap();
let result = provider.value_to_reference("pass://vault");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_invalid_pass_uri_empty_parts() {
let provider = ProtonPassProvider::new(None).unwrap();
let result = provider.value_to_reference("pass://vault//field");
assert!(result.is_err());
}
#[test]
fn test_value_to_reference_invalid_pass_uri_vault_item_only() {
let provider = ProtonPassProvider::new(None).unwrap();
let result = provider.value_to_reference("pass://vault/item");
assert!(result.is_err());
}
}