use regex::Regex;
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::sync::LazyLock;
use thiserror::Error;
use tokio::process::Command;
#[derive(Error, Debug)]
pub enum OnePasswordError {
#[error("1Password CLI not found. Please install from https://developer.1password.com/docs/cli/get-started/")]
CliNotFound,
#[error("Not authenticated with 1Password CLI. Run: op signin")]
NotAuthenticated,
#[error("Invalid 1Password reference format: {reference}")]
InvalidReference { reference: String },
#[error("Secret not found: {reference}")]
SecretNotFound { reference: String },
#[error("1Password CLI command failed: {message}")]
CommandFailed { message: String },
#[error("Permission denied accessing vault or item: {reference}")]
PermissionDenied { reference: String },
#[error("JSON parsing error: {source}")]
JsonError {
#[from]
source: serde_json::Error,
},
#[error("IO error: {source}")]
IoError {
#[from]
source: std::io::Error,
},
}
pub type OnePasswordResult<T> = Result<T, OnePasswordError>;
#[async_trait::async_trait]
pub trait SecretLoader {
async fn resolve_secret(&self, reference: &str) -> OnePasswordResult<String>;
async fn process_config(&self, config: &str) -> OnePasswordResult<String>;
async fn is_available(&self) -> bool;
}
#[derive(Debug, Clone)]
pub struct OnePasswordLoader {
timeout_seconds: u64,
}
impl Default for OnePasswordLoader {
fn default() -> Self {
Self::new()
}
}
impl OnePasswordLoader {
pub fn new() -> Self {
Self {
timeout_seconds: 30,
}
}
pub async fn check_cli_installed(&self) -> bool {
Command::new("op")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
pub async fn check_authenticated(&self) -> bool {
Command::new("op")
.arg("account")
.arg("list")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
pub fn parse_reference(&self, reference: &str) -> OnePasswordResult<OnePasswordRef> {
static OP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^op://([^/]+)/([^/]+)/([^/]+)$").expect("Invalid regex"));
if !reference.starts_with("op://") {
return Err(OnePasswordError::InvalidReference {
reference: reference.to_string(),
});
}
let captures =
OP_REGEX
.captures(reference)
.ok_or_else(|| OnePasswordError::InvalidReference {
reference: reference.to_string(),
})?;
Ok(OnePasswordRef {
vault: captures[1].to_string(),
item: captures[2].to_string(),
field: captures[3].to_string(),
})
}
}
#[async_trait::async_trait]
impl SecretLoader for OnePasswordLoader {
async fn resolve_secret(&self, reference: &str) -> OnePasswordResult<String> {
if !reference.starts_with("op://") {
return Ok(reference.to_string());
}
if !self.check_cli_installed().await {
return Err(OnePasswordError::CliNotFound);
}
if !self.check_authenticated().await {
return Err(OnePasswordError::NotAuthenticated);
}
let op_ref = self.parse_reference(reference)?;
let output = tokio::time::timeout(
tokio::time::Duration::from_secs(self.timeout_seconds),
Command::new("op")
.arg("item")
.arg("get")
.arg(&op_ref.item)
.arg("--vault")
.arg(&op_ref.vault)
.arg("--field")
.arg(&op_ref.field)
.output(),
)
.await
.map_err(|_| OnePasswordError::CommandFailed {
message: format!("Command timed out after {} seconds", self.timeout_seconds),
})?
.map_err(|e| OnePasswordError::CommandFailed {
message: format!("Failed to execute op command: {}", e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
return Err(OnePasswordError::SecretNotFound {
reference: reference.to_string(),
});
}
if stderr.contains("permission denied") {
return Err(OnePasswordError::PermissionDenied {
reference: reference.to_string(),
});
}
if stderr.contains("not signed in") {
return Err(OnePasswordError::NotAuthenticated);
}
return Err(OnePasswordError::CommandFailed {
message: stderr.to_string(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().to_string())
}
async fn process_config(&self, config: &str) -> OnePasswordResult<String> {
static OP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#""op://[^"]+""#).expect("Invalid regex"));
let mut result = config.to_string();
let matches: Vec<_> = OP_REGEX.find_iter(config).collect();
for mat in matches {
let reference = &config[mat.start() + 1..mat.end() - 1];
match self.resolve_secret(reference).await {
Ok(value) => {
result =
result.replace(&format!("\"{}\"", reference), &format!("\"{}\"", value));
}
Err(e) => {
log::error!("Failed to resolve secret {}: {}", reference, e);
return Err(e);
}
}
}
Ok(result)
}
async fn is_available(&self) -> bool {
self.check_cli_installed().await && self.check_authenticated().await
}
}
#[derive(Debug, Clone, Default)]
pub struct EnvironmentLoader;
impl EnvironmentLoader {
pub fn new() -> Self {
Self
}
fn reference_to_env_var(&self, reference: &str) -> OnePasswordResult<String> {
if !reference.starts_with("op://") {
return Ok(reference.to_string());
}
static OP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^op://([^/]+)/([^/]+)/([^/]+)$").expect("Invalid regex"));
let captures =
OP_REGEX
.captures(reference)
.ok_or_else(|| OnePasswordError::InvalidReference {
reference: reference.to_string(),
})?;
let vault = captures[1].to_uppercase().replace("-", "_");
let item = captures[2].to_uppercase().replace("-", "_");
let field = captures[3].to_uppercase();
Ok(format!("{}_{}_{}", vault, item, field))
}
}
#[async_trait::async_trait]
impl SecretLoader for EnvironmentLoader {
async fn resolve_secret(&self, reference: &str) -> OnePasswordResult<String> {
if !reference.starts_with("op://") {
return Ok(reference.to_string());
}
let env_var = self.reference_to_env_var(reference)?;
std::env::var(&env_var).map_err(|_| OnePasswordError::SecretNotFound {
reference: format!("{} (env var: {})", reference, env_var),
})
}
async fn process_config(&self, config: &str) -> OnePasswordResult<String> {
static OP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#""op://[^"]+""#).expect("Invalid regex"));
let mut result = config.to_string();
let matches: Vec<_> = OP_REGEX.find_iter(config).collect();
for mat in matches {
let reference = &config[mat.start() + 1..mat.end() - 1];
if let Ok(value) = self.resolve_secret(reference).await {
result = result.replace(&format!("\"{}\"", reference), &format!("\"{}\"", value));
}
}
Ok(result)
}
async fn is_available(&self) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnePasswordRef {
pub vault: String,
pub item: String,
pub field: String,
}
impl std::fmt::Display for OnePasswordRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "op://{}/{}/{}", self.vault, self.item, self.field)
}
}