use crate::env;
use crate::error::{FnoxError, Result};
use async_trait::async_trait;
use regex::Regex;
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
static ERROR_PREFIX_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^\[ERROR\] \d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} ").unwrap());
pub struct OnePasswordProvider {
vault: Option<String>,
account: Option<String>,
token: Option<String>,
}
impl OnePasswordProvider {
pub fn new(
vault: Option<String>,
account: Option<String>,
token: Option<String>,
) -> Result<Self> {
Ok(Self {
vault,
account,
token,
})
}
fn get_token(&self) -> Option<String> {
self.token
.as_ref()
.cloned()
.or_else(op_service_account_token)
}
fn value_to_reference(&self, value: &str) -> Result<String> {
if value.starts_with("op://") {
return Ok(value.to_string());
}
if self.vault.is_none() {
return Err(FnoxError::ProviderInvalidResponse {
provider: "1Password".to_string(),
details: format!("Unknown secret vault for: '{}'", value),
hint: "Specify a vault in the provider config or use a full 'op://' reference"
.to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
});
}
let parts: Vec<&str> = value.split('/').collect();
match parts.len() {
1 => Ok(format!(
"op://{}/{}/password",
self.vault.as_ref().unwrap(),
parts[0]
)),
2 => Ok(format!(
"op://{}/{}/{}",
self.vault.as_ref().unwrap(),
parts[0],
parts[1]
)),
_ => Err(FnoxError::ProviderInvalidResponse {
provider: "1Password".to_string(),
details: format!("Invalid secret reference format: '{}'", value),
hint: "Expected 'item', 'item/field', or 'op://vault/item/field'".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}),
}
}
async fn execute_op_command(&self, args: &[&str]) -> Result<String> {
tracing::debug!("Executing op command with args: {:?}", args);
let mut cmd = Command::new("op");
if let Some(token) = self.get_token() {
tracing::debug!(
"Setting OP_SERVICE_ACCOUNT_TOKEN (token length: {})",
token.len()
);
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token);
}
cmd.args(args);
if let Some(account) = &self.account {
cmd.arg("--account").arg(account);
}
let output = cmd.output().await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FnoxError::ProviderCliNotFound {
provider: "1Password".to_string(),
cli: "op".to_string(),
install_hint: "brew install 1password-cli".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}
} else {
FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: e.to_string(),
hint: "Check that the 1Password CLI is installed and accessible".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}
}
})?;
if !output.status.success() {
let cow = String::from_utf8_lossy(&output.stderr);
let replaced = ERROR_PREFIX_RE.replace_all(&cow, "");
let stderr = replaced.trim();
if stderr.contains("not signed in")
|| stderr.contains("signed in to an account")
|| stderr.contains("authenticate")
|| stderr.contains("authorization")
|| stderr.contains("session expired")
|| stderr.contains("invalid session")
{
return Err(FnoxError::ProviderAuthFailed {
provider: "1Password".to_string(),
details: stderr.to_string(),
hint: "Run 'op signin' or set OP_SERVICE_ACCOUNT_TOKEN".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
});
}
return Err(FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: stderr.to_string(),
hint: "Check your 1Password configuration and authentication".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
});
}
let stdout =
String::from_utf8(output.stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "1Password".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/1password".to_string(),
})?;
Ok(stdout.trim().to_string())
}
async fn execute_op_inject(&self, input: &str) -> Result<String> {
tracing::debug!("Executing op inject");
let mut cmd = Command::new("op");
if let Some(token) = self.get_token() {
tracing::debug!(
"Setting OP_SERVICE_ACCOUNT_TOKEN (token length: {})",
token.len()
);
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token);
}
if let Some(account) = &self.account {
cmd.arg("--account").arg(account);
}
cmd.arg("inject")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FnoxError::ProviderCliNotFound {
provider: "1Password".to_string(),
cli: "op".to_string(),
install_hint: "brew install 1password-cli".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}
} else {
FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: e.to_string(),
hint: "Check that the 1Password CLI is installed and accessible".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}
}
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input.as_bytes())
.await
.map_err(|e| FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: format!("Failed to write to stdin: {}", e),
hint: "This is an internal error".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
})?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: format!("Failed to wait for command: {}", e),
hint: "This is an internal error".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
})?;
if !output.status.success() {
let cow = String::from_utf8_lossy(&output.stderr);
let replaced = ERROR_PREFIX_RE.replace_all(&cow, "");
let stderr = replaced.trim();
if stderr.contains("not signed in")
|| stderr.contains("signed in to an account")
|| stderr.contains("authenticate")
|| stderr.contains("authorization")
|| stderr.contains("session expired")
|| stderr.contains("invalid session")
{
return Err(FnoxError::ProviderAuthFailed {
provider: "1Password".to_string(),
details: stderr.to_string(),
hint: "Run 'op signin' or set OP_SERVICE_ACCOUNT_TOKEN".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
});
}
return Err(FnoxError::ProviderCliFailed {
provider: "1Password".to_string(),
details: stderr.to_string(),
hint: "Check your 1Password configuration and authentication".to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
});
}
let stdout =
String::from_utf8(output.stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "1Password".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/1password".to_string(),
})?;
Ok(stdout)
}
}
#[async_trait]
impl crate::providers::Provider for OnePasswordProvider {
async fn get_secret(&self, value: &str) -> Result<String> {
tracing::debug!("Getting secret '{}' from 1Password", value);
let reference = self.value_to_reference(value)?;
tracing::debug!("Reading 1Password secret: {}", reference);
self.execute_op_command(&["read", &reference]).await
}
async fn get_secrets_batch(
&self,
secrets: &[(String, String)],
) -> HashMap<String, Result<String>> {
tracing::debug!(
"Getting {} secrets from 1Password using batch mode",
secrets.len()
);
if secrets.len() == 1 {
let (key, value) = &secrets[0];
let result = self.get_secret(value).await;
let mut map = HashMap::new();
map.insert(key.clone(), result);
return map;
}
let mut input = String::new();
let mut key_order = Vec::new();
let mut results = HashMap::new();
for (key, value) in secrets {
match self.value_to_reference(value) {
Ok(reference) => {
input.push_str(&format!("{}={}\n", key, reference));
key_order.push(key.clone());
}
Err(e) => {
tracing::warn!("Failed to build reference for '{}': {}", key, e);
results.insert(key.clone(), Err(e));
}
}
}
if key_order.is_empty() {
return results;
}
tracing::debug!("Injecting secrets with input:\n{}", input);
match self.execute_op_inject(&input).await {
Ok(output) => {
let mut current_key: Option<String> = None;
let mut current_value = String::new();
for line in output.lines() {
if let Some(eq_pos) = line.find('=') {
let potential_key = &line[..eq_pos];
if key_order.iter().any(|k| k == potential_key) {
if let Some(key) = current_key.take() {
results.insert(key, Ok(current_value.clone()));
}
current_key = Some(potential_key.to_string());
current_value = line[eq_pos + 1..].to_string();
continue;
}
}
if current_key.is_some() {
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
}
}
if let Some(key) = current_key {
results.insert(key, Ok(current_value));
}
for key in key_order {
if !results.contains_key(&key) {
results.insert(
key.clone(),
Err(FnoxError::ProviderSecretNotFound {
provider: "1Password".to_string(),
secret: key.clone(),
hint: "Check that the secret exists in your 1Password vault"
.to_string(),
url: "https://fnox.jdx.dev/providers/1password".to_string(),
}),
);
}
}
}
Err(e) => {
tracing::warn!("op inject failed, falling back to individual calls: {}", e);
for (key, value) in secrets {
if !results.contains_key(key) {
let result = self.get_secret(value).await;
results.insert(key.clone(), result);
}
}
}
}
results
}
async fn test_connection(&self) -> Result<()> {
tracing::debug!("Testing connection to 1Password");
let output = self.execute_op_command(&["whoami"]).await?;
tracing::debug!("1Password whoami output: {}", output);
Ok(())
}
}
pub fn env_dependencies() -> &'static [&'static str] {
&["OP_SERVICE_ACCOUNT_TOKEN", "FNOX_OP_SERVICE_ACCOUNT_TOKEN"]
}
fn op_service_account_token() -> Option<String> {
env::var("FNOX_OP_SERVICE_ACCOUNT_TOKEN")
.or_else(|_| env::var("OP_SERVICE_ACCOUNT_TOKEN"))
.ok()
}