use std::fmt;
use std::process::Command;
use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::store::StoreError;
const DEFAULT_FOLDER_PREFIX: &str = "basil/{project}/{profile}/{key}";
#[derive(Debug, Deserialize)]
struct OnePasswordField {
id: String,
#[serde(rename = "type")]
field_type: String,
label: Option<String>,
value: Option<SecretString>,
}
#[derive(Debug, Deserialize)]
struct OnePasswordItem {
fields: Vec<OnePasswordField>,
}
#[derive(Debug, Deserialize)]
struct OnePasswordListItem {
id: String,
title: String,
}
#[derive(Serialize)]
struct OnePasswordFieldTemplate {
label: String,
#[serde(rename = "type")]
field_type: String,
value: SecretString,
}
#[derive(Serialize)]
struct OnePasswordItemTemplate {
title: String,
category: String,
fields: Vec<OnePasswordFieldTemplate>,
tags: Vec<String>,
}
#[derive(Clone, Default)]
pub struct OnePasswordConfig {
pub account: Option<String>,
pub default_vault: Option<String>,
pub service_account_token: Option<Zeroizing<String>>,
pub folder_prefix: Option<String>,
}
impl fmt::Debug for OnePasswordConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OnePasswordConfig")
.field("account", &self.account)
.field("default_vault", &self.default_vault)
.field(
"service_account_token",
&self.service_account_token.as_ref().map(|_| "REDACTED"),
)
.field("folder_prefix", &self.folder_prefix)
.finish()
}
}
impl OnePasswordConfig {
pub fn from_uri(uri: &str) -> Result<Self, StoreError> {
let url =
Url::parse(uri).map_err(|_| StoreError::Backend("onepassword-bad-uri".to_owned()))?;
let scheme = url.scheme();
if scheme != "onepassword" && scheme != "onepassword+token" {
return Err(StoreError::Backend("onepassword-bad-scheme".to_owned()));
}
let mut config = Self::default();
if let Some(host) = url.host_str().map(decode)
&& host != "localhost"
{
let username = decode(url.username());
if !username.is_empty() {
if scheme == "onepassword+token" {
config.service_account_token =
Some(Zeroizing::new(url.password().map_or(username, decode)));
} else {
config.account = Some(username);
}
}
config.default_vault = Some(host);
}
Ok(config)
}
}
fn decode(raw: &str) -> String {
percent_decode_str(raw).decode_utf8_lossy().into_owned()
}
#[cfg(target_os = "linux")]
fn is_wsl2() -> bool {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.is_some_and(|content| content.trim().ends_with("-microsoft-standard-WSL2"))
}
#[cfg(not(target_os = "linux"))]
const fn is_wsl2() -> bool {
false
}
fn strip_op_session_env(cmd: &mut Command) {
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("OP_SESSION_") {
cmd.env_remove(&key);
}
}
}
enum OpErr {
NotFound,
Ambiguous,
Store(StoreError),
}
impl From<OpErr> for StoreError {
fn from(err: OpErr) -> Self {
match err {
OpErr::NotFound | OpErr::Ambiguous => Self::Backend("onepassword-op-failed".to_owned()),
OpErr::Store(store) => store,
}
}
}
pub struct OnePasswordProvider {
config: OnePasswordConfig,
op_command: String,
}
impl OnePasswordProvider {
#[must_use]
pub fn new(config: OnePasswordConfig) -> Self {
let op_command = std::env::var("BASIL_OP_CLI_PATH").unwrap_or_else(|_| {
if is_wsl2() {
"op.exe".to_owned()
} else {
"op".to_owned()
}
});
Self { config, op_command }
}
fn vault(&self) -> String {
self.config
.default_vault
.clone()
.unwrap_or_else(|| "Private".to_owned())
}
#[allow(clippy::literal_string_with_formatting_args)]
fn item_name(&self, project: &str, key: &str, profile: &str) -> String {
self.config
.folder_prefix
.as_deref()
.unwrap_or(DEFAULT_FOLDER_PREFIX)
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn run(&self, args: &[&str], stdin_data: Option<&str>) -> Result<Zeroizing<String>, OpErr> {
use std::io::Write as _;
use std::process::Stdio;
let mut cmd = Command::new(&self.op_command);
strip_op_session_env(&mut cmd);
if let Some(token) = &self.config.service_account_token {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token.as_str());
}
if let Some(account) = &self.config.account {
cmd.arg("--account").arg(account);
}
cmd.args(args);
let output = if let Some(data) = stdin_data {
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| spawn_err(&e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data.as_bytes()).map_err(|_| {
OpErr::Store(StoreError::Backend("onepassword-stdin".to_owned()))
})?;
drop(stdin);
}
child.wait_with_output().map_err(|_| {
OpErr::Store(StoreError::Backend("onepassword-op-failed".to_owned()))
})?
} else {
cmd.output().map_err(|e| spawn_err(&e))?
};
let stdout = Zeroizing::new(output.stdout);
if output.status.success() {
return String::from_utf8(stdout.to_vec())
.map(Zeroizing::new)
.map_err(|_| {
OpErr::Store(StoreError::Backend(
"onepassword-non-utf8-output".to_owned(),
))
});
}
let stderr_bytes = Zeroizing::new(output.stderr);
let stderr = String::from_utf8_lossy(&stderr_bytes);
if stderr.contains("isn't an item") {
Err(OpErr::NotFound)
} else if stderr.contains("More than one item") {
Err(OpErr::Ambiguous)
} else if stderr.contains("not currently signed in")
|| stderr.contains("no active session")
|| stderr.contains("could not find session token")
|| stderr.contains("account is not signed in")
|| stderr.contains("authentication required")
{
Err(OpErr::Store(StoreError::Backend(
"onepassword-auth-required".to_owned(),
)))
} else {
Err(OpErr::Store(StoreError::Backend(
"onepassword-op-failed".to_owned(),
)))
}
}
fn find_item_id(&self, item_name: &str, vault: &str) -> Result<Option<String>, StoreError> {
let out = self.run(
&["item", "list", "--vault", vault, "--format", "json"],
None,
)?;
let items: Vec<OnePasswordListItem> = serde_json::from_str(&out)
.map_err(|_| StoreError::Backend("onepassword-parse-item-list".to_owned()))?;
Ok(items
.into_iter()
.find(|item| item.title == item_name)
.map(|item| item.id))
}
fn extract_value(output: &str) -> Result<Option<Zeroizing<Vec<u8>>>, StoreError> {
let item: OnePasswordItem = serde_json::from_str(output)
.map_err(|_| StoreError::Backend("onepassword-parse-item".to_owned()))?;
for field in &item.fields {
if field.label.as_deref() == Some("value") {
return Ok(field.value.as_ref().map(SecretString::to_secret_bytes));
}
}
for field in &item.fields {
if field.field_type == "CONCEALED" || field.id == "password" {
return Ok(field.value.as_ref().map(SecretString::to_secret_bytes));
}
}
Ok(None)
}
pub fn get(
&self,
project: &str,
key: &str,
profile: &str,
) -> Result<Option<Zeroizing<Vec<u8>>>, StoreError> {
let vault = self.vault();
let item_name = self.item_name(project, key, profile);
let args = [
"item", "get", &item_name, "--vault", &vault, "--format", "json",
];
match self.run(&args, None) {
Ok(out) => Self::extract_value(&out),
Err(OpErr::NotFound) => Ok(None),
Err(OpErr::Ambiguous) => {
let Some(id) = self.find_item_id(&item_name, &vault)? else {
return Ok(None);
};
let args = ["item", "get", &id, "--vault", &vault, "--format", "json"];
match self.run(&args, None) {
Ok(out) => Self::extract_value(&out),
Err(OpErr::NotFound) => Ok(None),
Err(other) => Err(other.into()),
}
}
Err(other) => Err(other.into()),
}
}
pub fn set(
&self,
project: &str,
key: &str,
value: &[u8],
profile: &str,
) -> Result<(), StoreError> {
let text = std::str::from_utf8(value).map_err(|_| StoreError::NonUtf8Value)?;
let vault = self.vault();
let item_name = self.item_name(project, key, profile);
if let Some(id) = self.find_item_id(&item_name, &vault)? {
let args = ["item", "delete", &id, "--vault", &vault];
self.run(&args, None)?;
}
let template = OnePasswordItemTemplate {
title: item_name,
category: "SECURE_NOTE".to_owned(),
fields: vec![
OnePasswordFieldTemplate {
label: "project".to_owned(),
field_type: "STRING".to_owned(),
value: SecretString::new(project.to_owned()),
},
OnePasswordFieldTemplate {
label: "key".to_owned(),
field_type: "STRING".to_owned(),
value: SecretString::new(key.to_owned()),
},
OnePasswordFieldTemplate {
label: "value".to_owned(),
field_type: "STRING".to_owned(),
value: SecretString::new(text.to_owned()),
},
],
tags: vec!["automated".to_owned(), project.to_owned()],
};
let json = Zeroizing::new(
serde_json::to_string(&template)
.map_err(|_| StoreError::Backend("onepassword-template-encode".to_owned()))?,
);
let args = ["item", "create", "--vault", &vault, "-"];
self.run(&args, Some(&json))?;
Ok(())
}
}
struct SecretString(Zeroizing<String>);
impl SecretString {
fn new(value: String) -> Self {
Self(Zeroizing::new(value))
}
}
impl<'de> Deserialize<'de> for SecretString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| Self(Zeroizing::new(s)))
}
}
impl Serialize for SecretString {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("REDACTED")
}
}
impl SecretString {
fn to_secret_bytes(&self) -> Zeroizing<Vec<u8>> {
Zeroizing::new(self.0.as_bytes().to_vec())
}
}
fn spawn_err(err: &std::io::Error) -> OpErr {
if err.kind() == std::io::ErrorKind::NotFound {
OpErr::Store(StoreError::Backend(
"onepassword-cli-not-installed".to_owned(),
))
} else {
OpErr::Store(StoreError::Backend("onepassword-op-failed".to_owned()))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::{DEFAULT_FOLDER_PREFIX, OnePasswordConfig, OnePasswordProvider};
fn provider_from(uri: &str) -> OnePasswordProvider {
OnePasswordProvider::new(OnePasswordConfig::from_uri(uri).expect("valid uri"))
}
#[test]
fn uri_parses_account_and_vault() {
let c = OnePasswordConfig::from_uri("onepassword://work@Production").unwrap();
assert_eq!(c.account.as_deref(), Some("work"));
assert_eq!(c.default_vault.as_deref(), Some("Production"));
assert!(c.service_account_token.is_none());
}
#[test]
fn uri_parses_vault_only() {
let c = OnePasswordConfig::from_uri("onepassword://Production").unwrap();
assert!(c.account.is_none());
assert_eq!(c.default_vault.as_deref(), Some("Production"));
}
#[test]
fn uri_percent_decodes_vault_name() {
let c = OnePasswordConfig::from_uri("onepassword://Home%20Lab").unwrap();
assert_eq!(c.default_vault.as_deref(), Some("Home Lab"));
}
#[test]
fn uri_token_scheme_captures_token_from_username() {
let c = OnePasswordConfig::from_uri("onepassword+token://ops_tok@Private").unwrap();
assert_eq!(
c.service_account_token.as_ref().map(|s| s.as_str()),
Some("ops_tok")
);
assert_eq!(c.default_vault.as_deref(), Some("Private"));
assert!(c.account.is_none());
}
#[test]
fn uri_token_scheme_captures_token_from_password() {
let c = OnePasswordConfig::from_uri("onepassword+token://acct:ops_tok@Private").unwrap();
assert_eq!(
c.service_account_token.as_ref().map(|s| s.as_str()),
Some("ops_tok")
);
}
#[test]
fn config_debug_redacts_service_account_token() {
let c = OnePasswordConfig::from_uri("onepassword+token://acct:ops_tok@Private").unwrap();
let rendered = format!("{c:?}");
assert!(rendered.contains("REDACTED"));
assert!(!rendered.contains("ops_tok"));
}
#[test]
fn uri_ignores_localhost_host() {
let c = OnePasswordConfig::from_uri("onepassword://localhost").unwrap();
assert!(c.default_vault.is_none());
assert!(c.account.is_none());
}
#[test]
fn uri_rejects_unknown_scheme() {
let err = OnePasswordConfig::from_uri("keyring://vault").unwrap_err();
assert!(matches!(err, super::StoreError::Backend(_)));
}
#[test]
fn uri_rejects_garbage() {
assert!(OnePasswordConfig::from_uri("not a uri at all").is_err());
}
#[test]
fn vault_defaults_to_private() {
assert_eq!(provider_from("onepassword://localhost").vault(), "Private");
assert_eq!(
provider_from("onepassword://Production").vault(),
"Production"
);
}
#[test]
fn item_name_default_and_custom() {
let default = provider_from("onepassword://Production");
assert_eq!(
default.item_name("proj", "KEY", "prod"),
"basil/proj/prod/KEY"
);
assert_eq!(DEFAULT_FOLDER_PREFIX, "basil/{project}/{profile}/{key}");
let mut cfg = OnePasswordConfig::from_uri("onepassword://Production").unwrap();
cfg.folder_prefix = Some("{project}-{key}".to_owned());
let custom = OnePasswordProvider::new(cfg);
assert_eq!(custom.item_name("proj", "KEY", "prod"), "proj-KEY");
}
}