use std::collections::HashMap;
use std::process::Command;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use crate::MonosecretError;
use crate::Result;
use crate::config::SecretRequest;
use crate::provider::Provider;
use crate::provider::ProviderUrl;
const ONEPASSWORD_BATCH_PARALLELISM: usize = 8;
fn collect_bounded_parallel<T, R, F>(
jobs: Vec<T>,
max_parallel: usize,
panic_message: &'static str,
run: F,
) -> Vec<Result<R>>
where
T: Send,
R: Send,
F: Fn(T) -> R + Sync,
{
let max_parallel = max_parallel.max(1);
let mut results = Vec::with_capacity(jobs.len());
std::thread::scope(|scope| {
let mut jobs = jobs.into_iter();
loop {
let handles: Vec<_> = jobs
.by_ref()
.take(max_parallel)
.map(|job| {
let run = &run;
scope.spawn(move || run(job))
})
.collect();
if handles.is_empty() {
break;
}
results.extend(handles.into_iter().map(|handle| {
handle
.join()
.map_err(|_| MonosecretError::ProviderOperationFailed(panic_message.into()))
}));
}
});
results
}
#[derive(Debug, Deserialize)]
pub(crate) struct OnePasswordItem {
pub(crate) fields: Vec<OnePasswordField>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OnePasswordField {
id: String,
#[serde(rename = "type")]
field_type: String,
pub(crate) label: Option<String>,
pub(crate) section: Option<OnePasswordSection>,
pub(crate) value: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OnePasswordSection {
pub(crate) label: Option<String>,
}
#[derive(Debug, Serialize)]
struct OnePasswordItemTemplate {
title: String,
category: String,
fields: Vec<OnePasswordFieldTemplate>,
tags: Vec<String>,
}
#[derive(Debug, Serialize)]
struct OnePasswordFieldTemplate {
label: String,
#[serde(rename = "type")]
field_type: String,
value: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OnePasswordConfig {
pub account: Option<String>,
pub default_vault: Option<String>,
pub service_account_token: Option<String>,
pub folder_prefix: Option<String>,
#[serde(default)]
pub native_references: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_base_path: Vec<String>,
}
impl TryFrom<&ProviderUrl> for OnePasswordConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
match scheme {
"1password" => {
return Err(MonosecretError::ProviderOperationFailed(
"Invalid scheme '1password'. Use 'onepassword' instead (e.g., onepassword://vault/path)".to_string()
));
}
"onepassword" | "onepassword+token" | "op" | "op+token" => {}
_ => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{scheme}' for OnePassword provider"
)));
}
}
let mut config = Self {
native_references: matches!(scheme, "op" | "op+token"),
..Self::default()
};
if let Some(host) = url.host()
&& host != "localhost"
{
let username = url.username();
if username.is_empty() {
config.default_vault = Some(host);
} else {
if scheme == "onepassword+token" || scheme == "op+token" {
if let Some(password) = url.password() {
config.service_account_token = Some(password);
} else {
config.service_account_token = Some(username);
}
} else {
config.account = Some(username);
}
config.default_vault = Some(host);
}
}
let uri_path = url.path();
let uri_path = uri_path.trim_matches('/');
if !uri_path.is_empty() {
if config.native_references {
config.reference_base_path = uri_path
.split('/')
.filter(|segment| !segment.is_empty())
.map(str::to_string)
.collect();
} else {
let folder_prefix = if uri_path.contains("{key}") {
uri_path.to_string()
} else {
format!("{}/{{key}}", uri_path.trim_end_matches('/'))
};
config.folder_prefix = Some(folder_prefix);
}
}
Ok(config)
}
}
#[cfg(target_os = "linux")]
fn is_wsl2() -> bool {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|content| content.trim().ends_with("-microsoft-standard-WSL2"))
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
fn is_wsl2() -> bool {
false
}
const OP_NOT_INSTALLED_HELP: &str = "OnePassword CLI (op) is not installed.\n\n\
To install it:\n \
- macOS: brew install 1password-cli\n \
- Linux: Download from https://1password.com/downloads/command-line/\n \
- Windows: Download from https://1password.com/downloads/command-line/\n \
- NixOS: nix-env -iA nixpkgs.onepassword\n\n\
Then enable desktop integration in the 1Password app under\n \
Settings → Developer → \"Integrate with 1Password CLI\".";
const AUTH_REQUIRED_HELP: &str = "OnePassword authentication required.\n\n\
Recommended: enable desktop integration in the 1Password app under\n \
Settings → Developer → \"Integrate with 1Password CLI\", then unlock the app.\n\n\
Alternatives:\n \
- Service account (CI): set OP_SERVICE_ACCOUNT_TOKEN or use the onepassword+token:// or op+token:// scheme\n \
- Manual signin: run 'eval $(op signin)' (session expires after 30 minutes of inactivity)";
fn is_auth_error(error_msg: &str) -> bool {
error_msg.contains("not currently signed in")
|| error_msg.contains("no active session")
|| error_msg.contains("could not find session token")
|| error_msg.contains("account is not signed in")
}
pub(crate) 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);
}
}
}
pub struct OnePasswordProvider {
config: OnePasswordConfig,
op_command: String,
dependency_env: HashMap<String, SecretString>,
}
crate::register_provider! {
struct: OnePasswordProvider,
config: OnePasswordConfig,
name: "onepassword",
description: "OnePassword password manager",
schemes: ["onepassword", "onepassword+token", "op", "op+token"],
examples: [
"onepassword://vault",
"onepassword://work@Production",
"onepassword+token://vault",
"op://vault/item/section",
"op+token://vault/item",
],
preflight: check_auth,
}
impl OnePasswordProvider {
pub fn new(config: OnePasswordConfig) -> Self {
let op_command = std::env::var("MONOSECRET_OPCLI_PATH")
.or_else(|_| std::env::var("SECRETSPEC_OPCLI_PATH"))
.unwrap_or_else(|_| {
if is_wsl2() {
"op.exe".to_string()
} else {
"op".to_string()
}
});
Self {
config,
op_command,
dependency_env: HashMap::new(),
}
}
fn execute_op_command(&self, args: &[&str], stdin_data: Option<&str>) -> Result<String> {
use std::io::Write;
use std::process::Stdio;
tracing::debug!(
command = %self.op_command,
args = ?args,
has_stdin = stdin_data.is_some(),
has_service_token = self.config.service_account_token.is_some()
|| self.dependency_env.contains_key("OP_SERVICE_ACCOUNT_TOKEN"),
account = ?self.config.account,
vault = ?self.config.default_vault,
"executing 1Password CLI command"
);
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);
} else if let Some(token) = self.dependency_env.get("OP_SERVICE_ACCOUNT_TOKEN") {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token.expose_secret());
}
if let Some(account) = &self.config.account {
cmd.arg("--account").arg(account);
}
cmd.args(args);
if stdin_data.is_some() {
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
}
let output = if let Some(data) = stdin_data {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(MonosecretError::ProviderOperationFailed(
OP_NOT_INSTALLED_HELP.to_string(),
));
}
Err(e) => return Err(e.into()),
};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data.as_bytes())?;
drop(stdin); }
child.wait_with_output()?
} else {
match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(MonosecretError::ProviderOperationFailed(
OP_NOT_INSTALLED_HELP.to_string(),
));
}
Err(e) => return Err(e.into()),
}
};
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if is_auth_error(&error_msg) {
tracing::error!(
status = ?output.status.code(),
stderr = %error_msg,
args = ?args,
"1Password CLI command failed due to authentication"
);
return Err(MonosecretError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
));
}
tracing::warn!(
status = ?output.status.code(),
stderr = %error_msg,
args = ?args,
"1Password CLI command failed"
);
return Err(MonosecretError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|e| MonosecretError::ProviderOperationFailed(e.to_string()))
}
fn is_authenticated(&self) -> Result<bool> {
match self.execute_op_command(&["vault", "list", "--format", "json"], None) {
Ok(_) => Ok(true),
Err(MonosecretError::ProviderOperationFailed(msg))
if msg.contains("authentication required") || msg.contains("no account found") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
fn get_vault_name(&self, _profile: &str) -> String {
self.config
.default_vault
.clone()
.unwrap_or_else(|| "Private".to_string())
}
fn find_item_id(&self, item_name: &str, vault: &str) -> Result<Option<String>> {
let args = vec!["item", "list", "--vault", vault, "--format", "json"];
let output = self.execute_op_command(&args, None)?;
#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}
let items: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
Ok(items
.into_iter()
.find(|item| item.title == item_name)
.map(|item| item.id))
}
fn format_item_name(&self, project: &str, key: &str, profile: &str) -> String {
let format_string = self
.config
.folder_prefix
.as_deref()
.unwrap_or("monosecret/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn create_item_template(
&self,
project: &str,
key: &str,
value: &SecretString,
profile: &str,
) -> OnePasswordItemTemplate {
OnePasswordItemTemplate {
title: self.format_item_name(project, key, profile),
category: "SECURE_NOTE".to_string(),
fields: vec![
OnePasswordFieldTemplate {
label: "project".to_string(),
field_type: "STRING".to_string(),
value: project.to_string(),
},
OnePasswordFieldTemplate {
label: "key".to_string(),
field_type: "STRING".to_string(),
value: key.to_string(),
},
OnePasswordFieldTemplate {
label: "value".to_string(),
field_type: "STRING".to_string(),
value: value.expose_secret().to_string(),
},
],
tags: vec!["automated".to_string(), project.to_string()],
}
}
fn extract_value_from_item(output: &str) -> Result<Option<SecretString>> {
let item: OnePasswordItem = serde_json::from_str(output)?;
for field in &item.fields {
if field.label.as_deref() == Some("value") {
return Ok(field
.value
.as_ref()
.map(|v| SecretString::new(v.clone().into())));
}
}
for field in &item.fields {
if field.field_type == "CONCEALED" || field.id == "password" {
return Ok(field
.value
.as_ref()
.map(|v| SecretString::new(v.clone().into())));
}
}
Ok(None)
}
}
impl OnePasswordProvider {
pub(crate) fn check_auth(&self) -> Result<()> {
if self.is_authenticated()? {
Ok(())
} else {
Err(MonosecretError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
))
}
}
fn native_reference_parts(&self, key: &str, request: Option<&SecretRequest>) -> Vec<String> {
let mut parts = self.config.reference_base_path.clone();
if let Some(request) = request {
if let Some(path) = &request.path {
parts.extend(path.iter().cloned());
}
parts.push(request.key.as_deref().unwrap_or(key).to_string());
} else {
parts.push(key.to_string());
}
parts
}
fn native_reference(&self, profile: &str, parts: &[String]) -> String {
let mut reference = format!(
"op://{}",
ProviderUrl::encode(&self.get_vault_name(profile))
);
for part in parts {
reference.push('/');
reference.push_str(&ProviderUrl::encode(part));
}
reference
}
fn native_item_and_assignment(
&self,
key: &str,
value: &SecretString,
request: Option<&SecretRequest>,
) -> Result<(String, String)> {
let parts = self.native_reference_parts(key, request);
if parts.len() < 2 {
return Err(MonosecretError::ProviderOperationFailed(
"Native 1Password provider requires an item path before the field name".into(),
));
}
let item = parts[0].clone();
let field = parts.last().expect("parts length checked");
let assignment_name = if parts.len() > 2 {
format!("{}.{}", parts[1..parts.len() - 1].join("."), field)
} else {
field.clone()
};
Ok((
item,
format!("{}[concealed]={}", assignment_name, value.expose_secret()),
))
}
fn native_missing_message(message: &str) -> bool {
message.contains("isn't an item")
|| message.contains("isn't a field")
|| message.contains("couldn't find")
|| message.contains("not found")
}
fn read_native_reference(
&self,
key: &str,
profile: &str,
request: Option<&SecretRequest>,
) -> Result<Option<SecretString>> {
let parts = self.native_reference_parts(key, request);
if parts.len() < 2 {
return Ok(None);
}
let reference = self.native_reference(profile, &parts);
let args = vec!["read", reference.as_str()];
match self.execute_op_command(&args, None) {
Ok(output) => {
Ok(Some(SecretString::new(
output.trim_end_matches(['\r', '\n']).to_string().into(),
)))
}
Err(MonosecretError::ProviderOperationFailed(msg))
if Self::native_missing_message(&msg) =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
fn set_native_reference(
&self,
key: &str,
value: &SecretString,
profile: &str,
request: Option<&SecretRequest>,
) -> Result<()> {
let vault = self.get_vault_name(profile);
if self.read_native_reference(key, profile, request)?.is_none() {
return Err(MonosecretError::ProviderOperationFailed(
"Cannot set native 1Password reference because it does not exist".into(),
));
}
let (item, assignment) = self.native_item_and_assignment(key, value, request)?;
let edit_args = vec![
"item",
"edit",
item.as_str(),
"--vault",
vault.as_str(),
assignment.as_str(),
];
self.execute_op_command(&edit_args, None).map(|_| ())
}
}
impl Provider for OnePasswordProvider {
fn configure_dependency_secrets(
&mut self,
dependencies: &[(String, SecretString)],
) -> Result<()> {
for (name, value) in dependencies {
if name == "OP_SERVICE_ACCOUNT_TOKEN" {
self.dependency_env.insert(name.clone(), value.clone());
}
}
Ok(())
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let scheme = match (
self.config.native_references,
self.config.service_account_token.is_some(),
) {
(true, true) => "op+token",
(true, false) => "op",
(false, true) => "onepassword+token",
(false, false) => "onepassword",
};
let mut uri = format!("{scheme}://");
if self.config.service_account_token.is_some() {
if let Some(ref vault) = self.config.default_vault {
uri.push_str(&ProviderUrl::encode(vault));
}
} else {
if let Some(ref account) = self.config.account {
uri.push_str(&ProviderUrl::encode(account));
uri.push('@');
}
if let Some(ref vault) = self.config.default_vault {
uri.push_str(&ProviderUrl::encode(vault));
}
}
if self.config.native_references {
for segment in &self.config.reference_base_path {
uri.push('/');
uri.push_str(&ProviderUrl::encode(segment));
}
}
uri
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
if self.config.native_references {
return self.read_native_reference(key, profile, None);
}
let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
let args = vec![
"item", "get", &item_name, "--vault", &vault, "--format", "json",
];
match self.execute_op_command(&args, None) {
Ok(output) => Self::extract_value_from_item(&output),
Err(MonosecretError::ProviderOperationFailed(msg)) if msg.contains("isn't an item") => {
Ok(None)
}
Err(MonosecretError::ProviderOperationFailed(msg))
if msg.contains("More than one item") =>
{
if let Some(item_id) = self.find_item_id(&item_name, &vault)? {
let args = vec![
"item", "get", &item_id, "--vault", &vault, "--format", "json",
];
match self.execute_op_command(&args, None) {
Ok(output) => Self::extract_value_from_item(&output),
Err(e) => Err(e),
}
} else {
Ok(None)
}
}
Err(e) => Err(e),
}
}
fn get_with_request(
&self,
project: &str,
key: &str,
profile: &str,
request: &SecretRequest,
) -> Result<Option<SecretString>> {
if self.config.native_references {
return self.read_native_reference(key, profile, Some(request));
}
let storage_key = request.key.as_deref().unwrap_or(key);
let Some(path_segments) = &request.path else {
return self.get(project, storage_key, profile);
};
if path_segments.is_empty() {
return self.get(project, storage_key, profile);
}
let vault = self.get_vault_name(profile);
let item_name = path_segments[0].as_str();
let field_key = request.key.as_deref().unwrap_or(key);
let section_name = path_segments.get(1).map(String::as_str);
tracing::debug!(
item = %item_name,
vault = %vault,
section = ?section_name,
field = %field_key,
"reading 1Password field via provider-relative request"
);
let args = vec![
"item", "get", item_name, "--vault", &vault, "--format", "json",
];
let output = match self.execute_op_command(&args, None) {
Ok(output) => output,
Err(MonosecretError::ProviderOperationFailed(msg)) if msg.contains("isn't an item") => {
return Ok(None);
}
Err(e) => return Err(e),
};
let item: OnePasswordItem = match serde_json::from_str(&output) {
Ok(item) => item,
Err(e) => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Failed to parse OnePassword item JSON: {e}"
)));
}
};
for field in &item.fields {
let section_match = section_name.is_none_or(|section_name| {
field.section.as_ref().and_then(|s| s.label.as_deref()) == Some(section_name)
});
let label_match = field.label.as_deref() == Some(field_key);
if section_match
&& label_match
&& let Some(ref value) = field.value
{
return Ok(Some(SecretString::new(value.clone().into())));
}
}
tracing::warn!(
item = %item_name,
vault = %vault,
section = ?section_name,
field = %field_key,
"1Password item did not contain requested section/field"
);
Ok(None)
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
if self.config.native_references {
return self.set_native_reference(key, value, profile, None);
}
let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
if let Some(item_id) = self.find_item_id(&item_name, &vault)? {
let field_assignment = format!("value={}", value.expose_secret());
let args = vec![
"item",
"edit",
&item_id,
"--vault",
&vault,
&field_assignment,
];
self.execute_op_command(&args, None)?;
} else {
let template = self.create_item_template(project, key, value, profile);
let template_json = serde_json::to_string(&template)?;
let args = vec!["item", "create", "--vault", &vault, "-"];
self.execute_op_command(&args, Some(&template_json))?;
}
Ok(())
}
fn set_with_request(
&self,
project: &str,
key: &str,
value: &SecretString,
profile: &str,
request: &SecretRequest,
) -> Result<()> {
if self.config.native_references {
return self.set_native_reference(key, value, profile, Some(request));
}
let storage_key = request.key.as_deref().unwrap_or(key);
self.set(project, storage_key, value, profile)
}
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
if keys.is_empty() {
return Ok(HashMap::new());
}
if self.config.native_references {
let jobs: Vec<&str> = keys.to_vec();
let mut results = HashMap::new();
for outcome in collect_bounded_parallel(
jobs,
ONEPASSWORD_BATCH_PARALLELISM,
"Native 1Password batch read worker panicked",
|key| {
self.read_native_reference(key, profile, None)
.map(|value| value.map(|value| (key.to_string(), value)))
},
) {
match outcome {
Ok(Ok(Some((key, value)))) => {
results.insert(key, value);
}
Ok(Ok(None)) => {}
Ok(Err(e)) | Err(e) => return Err(e),
}
}
return Ok(results);
}
let vault = self.get_vault_name(profile);
let args = vec!["item", "list", "--vault", &vault, "--format", "json"];
let output = self.execute_op_command(&args, None)?;
#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}
let items: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
let item_map: HashMap<String, String> = items
.into_iter()
.map(|item| (item.title, item.id))
.collect();
let keys_to_fetch: Vec<(&str, String)> = keys
.iter()
.filter_map(|key| {
let item_name = self.format_item_name(project, key, profile);
item_map.get(&item_name).map(|id| (*key, id.clone()))
})
.collect();
let outcomes = collect_bounded_parallel(
keys_to_fetch,
ONEPASSWORD_BATCH_PARALLELISM,
"1Password batch read worker panicked",
|(key, item_id)| {
let args = vec![
"item", "get", &item_id, "--vault", &vault, "--format", "json",
];
match self.execute_op_command(&args, None) {
Ok(output) => {
Self::extract_value_from_item(&output)
.ok()
.flatten()
.map(|value| (key.to_string(), value))
}
Err(_) => None,
}
},
);
let mut results = HashMap::new();
for outcome in outcomes {
if let Ok(Some((key, value))) = outcome {
results.insert(key, value);
}
}
Ok(results)
}
}
impl Default for OnePasswordProvider {
fn default() -> Self {
Self::new(OnePasswordConfig::default())
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::*;
fn config(s: &str) -> OnePasswordConfig {
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap()
}
#[test]
fn try_from_parses_account_and_vault() {
let c = config("onepassword://work@Production");
assert_eq!(c.account.as_deref(), Some("work"));
assert_eq!(c.default_vault.as_deref(), Some("Production"));
assert_eq!(c.service_account_token, None);
}
#[test]
fn try_from_parses_vault_only() {
let c = config("onepassword://Production");
assert_eq!(c.account, None);
assert_eq!(c.default_vault.as_deref(), Some("Production"));
}
#[test]
fn try_from_token_scheme_captures_token_from_username() {
let c = config("onepassword+token://ops_tok@Private");
assert_eq!(c.service_account_token.as_deref(), Some("ops_tok"));
assert_eq!(c.default_vault.as_deref(), Some("Private"));
assert_eq!(c.account, None);
}
#[test]
fn try_from_token_scheme_captures_token_from_password() {
let c = config("onepassword+token://acct:ops_tok@Private");
assert_eq!(c.service_account_token.as_deref(), Some("ops_tok"));
}
#[test]
fn try_from_ignores_localhost_host() {
let c = config("onepassword://localhost");
assert_eq!(c.default_vault, None);
assert_eq!(c.account, None);
}
#[test]
fn try_from_rejects_unknown_scheme() {
let err =
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse("keyring://vault").unwrap()))
.unwrap_err();
assert!(err.to_string().contains("Invalid scheme"));
}
#[test]
fn get_vault_name_defaults_to_private() {
let default = OnePasswordProvider::new(OnePasswordConfig::default());
assert_eq!(default.get_vault_name("any"), "Private");
let configured = OnePasswordProvider::new(config("onepassword://Production"));
assert_eq!(configured.get_vault_name("any"), "Production");
}
#[test]
fn format_item_name_default_and_custom() {
let default = OnePasswordProvider::new(OnePasswordConfig::default());
assert_eq!(
default.format_item_name("proj", "KEY", "prod"),
"monosecret/proj/prod/KEY"
);
let custom = OnePasswordProvider::new(OnePasswordConfig {
folder_prefix: Some("{project}-{key}".to_string()),
..Default::default()
});
assert_eq!(custom.format_item_name("proj", "KEY", "prod"), "proj-KEY");
}
#[test]
fn uri_for_account_round_trips() {
let provider = OnePasswordProvider::new(config("onepassword://work@Production"));
assert_eq!(provider.uri(), "onepassword://work@Production");
}
#[test]
fn uri_for_token_does_not_leak_secret() {
let provider =
OnePasswordProvider::new(config("onepassword+token://ops_secret_tok@Private"));
let uri = provider.uri();
assert_eq!(uri, "onepassword+token://Private");
assert!(!uri.contains("ops_secret_tok"));
}
}
#[cfg(all(test, unix))]
mod dependency_env_tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use secrecy::ExposeSecret;
use super::*;
fn write_op_stub(script: &std::path::Path, log: &std::path::Path) {
let script_body = format!(
r#"#!/bin/sh
printf '%s\n' "$OP_SERVICE_ACCOUNT_TOKEN" >> '{}'
printf '{{"fields":[{{"id":"value","type":"CONCEALED","label":"value","value":"%s"}}]}}\n' "$OP_SERVICE_ACCOUNT_TOKEN"
"#,
log.display()
);
fs::write(script, script_body).unwrap();
let mut permissions = fs::metadata(script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(script, permissions).unwrap();
}
#[test]
fn dependency_token_is_command_scoped_for_every_op_invocation() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("op-stub");
let log = temp.path().join("calls.log");
write_op_stub(&script, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
service_account_token: None,
default_vault: Some("Development".into()),
..OnePasswordConfig::default()
});
provider.op_command = script.display().to_string();
provider
.configure_dependency_secrets(&[
("IGNORED".into(), SecretString::new("ignored".into())),
(
"OP_SERVICE_ACCOUNT_TOKEN".into(),
SecretString::new("dependency-token".into()),
),
])
.unwrap();
let first = provider
.get("project", "API_KEY", "default")
.unwrap()
.unwrap();
let second = provider
.get("project", "API_KEY", "default")
.unwrap()
.unwrap();
assert_eq!(first.expose_secret(), "dependency-token");
assert_eq!(second.expose_secret(), "dependency-token");
assert_eq!(
fs::read_to_string(log).unwrap(),
"dependency-token\ndependency-token\n"
);
}
#[test]
fn explicit_uri_token_takes_precedence_over_dependency_token() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("op-stub");
let log = temp.path().join("calls.log");
write_op_stub(&script, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
service_account_token: Some("uri-token".into()),
default_vault: Some("Development".into()),
..OnePasswordConfig::default()
});
provider.op_command = script.display().to_string();
provider
.configure_dependency_secrets(&[(
"OP_SERVICE_ACCOUNT_TOKEN".into(),
SecretString::new("dependency-token".into()),
)])
.unwrap();
let value = provider
.get("project", "API_KEY", "default")
.unwrap()
.unwrap();
assert_eq!(value.expose_secret(), "uri-token");
assert_eq!(fs::read_to_string(log).unwrap(), "uri-token\n");
}
}
#[cfg(test)]
mod native_and_batch_tests {
use std::fs;
use secrecy::ExposeSecret;
use super::*;
#[cfg(unix)]
fn write_fake_op(temp_dir: &tempfile::TempDir, log: &std::path::Path) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = temp_dir.path().join("op");
fs::write(
&script,
format!(
r#"#!/usr/bin/env sh
printf '%s\n' "$*" >> '{0}'
case "$1 $2" in
read*)
case "$2" in
op://Development/dotfiles/forges/GITHUB_TOKEN)
printf '%s\n' 'native-github-token'
;;
op://Development/dotfiles/GITHUB_TOKEN)
printf '%s\n' 'native-root-github-token'
;;
op://Development/dotfiles/error/GITHUB_TOKEN)
printf '%s\n' 'permission denied' >&2
exit 1
;;
*)
printf '%s\n' 'not found' >&2
exit 1
;;
esac
;;
"item edit")
printf '%s\n' 'edited'
;;
"item create")
cat >> '{0}'
printf '%s\n' 'created'
;;
"item list")
printf '%s\n' '[{{"id":"id-stored-item","title":"monosecret/proj/default/STORED_ITEM"}},{{"id":"id-broken","title":"monosecret/proj/default/BROKEN"}},{{"id":"id-duplicate","title":"monosecret/proj/default/DUPLICATE_ITEM"}}]'
;;
"item get")
case "$3" in
id-stored-item)
printf '%s\n' '{{"fields":[{{"id":"value","type":"STRING","label":"value","value":"from-batch-item"}}]}}'
;;
*STORED_ITEM*)
printf '%s\n' '{{"fields":[{{"id":"value","type":"STRING","label":"value","value":"from-stored-item"}}]}}'
;;
*DUPLICATE_ITEM*)
printf '%s\n' 'More than one item matching this title was found' >&2
exit 1
;;
id-duplicate)
printf '%s\n' '{{"fields":[{{"id":"value","type":"STRING","label":"value","value":"from-duplicate-id"}}]}}'
;;
dotfiles)
printf '%s\n' '{{"fields":[{{"id":"github","type":"CONCEALED","label":"GITHUB_TOKEN","section":{{"label":"forges"}},"value":"from-dotfiles-item"}}]}}'
;;
*)
printf '%s\n' "isn't an item" >&2
exit 1
;;
esac
;;
*)
printf '%s\n' '[]'
;;
esac
"#,
log.display()
),
)
.expect("write fake op script");
let mut permissions = fs::metadata(&script)
.expect("stat fake op script")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).expect("make fake op executable");
script
}
fn init_test_tracing() {}
fn call_log_with_sorted_tail(path: &std::path::Path, prefix_len: usize) -> String {
let contents = fs::read_to_string(path).expect("read fake op call log");
let mut lines: Vec<_> = contents.lines().map(str::to_string).collect();
let split_at = prefix_len.min(lines.len());
let mut tail = lines.split_off(split_at);
tail.sort();
lines.extend(tail);
let mut normalized = lines.join("\n");
normalized.push('\n');
normalized
}
#[test]
fn collect_bounded_parallel_maps_worker_panics_to_provider_errors() {
let panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcomes = collect_bounded_parallel(vec![1, 2], 1, "worker failed", |job| {
assert!(job != 2, "boom");
job
});
std::panic::set_hook(panic_hook);
assert_eq!(outcomes[0].as_ref().copied().unwrap(), 1);
let err = outcomes[1].as_ref().expect_err("panic maps to error");
assert!(err.to_string().contains("worker failed"));
}
#[test]
#[cfg(unix)]
fn onepassword_get_batch_fetches_legacy_items_with_bounded_workers() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let batch = provider
.get_batch("proj", &["STORED_ITEM", "BROKEN", "MISSING"], "default")
.expect("batch legacy reads");
assert_eq!(batch["STORED_ITEM"].expose_secret(), "from-batch-item");
assert!(!batch.contains_key("BROKEN"));
assert!(!batch.contains_key("MISSING"));
let calls = call_log_with_sorted_tail(&log, 1);
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn onepassword_get_falls_back_to_id_lookup_when_title_is_ambiguous() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let value = provider
.get("proj", "DUPLICATE_ITEM", "default")
.expect("fallback to ID lookup should succeed")
.expect("value should be found via id-duplicate");
assert_eq!(value.expose_secret(), "from-duplicate-id");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn op_get_batch_propagates_native_reference_errors() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string(), "error".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let err = provider
.get_batch("dotfiles", &["GITHUB_TOKEN"], "default")
.expect_err("native batch read should propagate non-missing errors");
insta::assert_snapshot!(err.to_string());
}
#[test]
#[cfg(unix)]
fn onepassword_get_with_request_uses_key_hint_when_path_is_absent_or_empty() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig::default());
provider.op_command = op.display().to_string();
let no_path_request = SecretRequest {
path: None,
key: Some("STORED_ITEM".to_string()),
};
let empty_path_request = SecretRequest {
path: Some(Vec::new()),
key: Some("STORED_ITEM".to_string()),
};
let no_path_value = provider
.get_with_request("proj", "SECRET_NAME", "default", &no_path_request)
.expect("read with key hint and no path")
.expect("value from fake op");
let empty_path_value = provider
.get_with_request("proj", "SECRET_NAME", "default", &empty_path_request)
.expect("read with key hint and empty path")
.expect("value from fake op");
assert_eq!(no_path_value.expose_secret(), "from-stored-item");
assert_eq!(empty_path_value.expose_secret(), "from-stored-item");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
let missing_value = provider
.get("proj", "MISSING_ITEM", "default")
.expect("missing item should not be a hard error");
assert!(missing_value.is_none());
}
#[test]
fn onepassword_uri_path_becomes_provider_relative_item_root() {
let provider_url =
ProviderUrl::new(url::Url::parse("onepassword+token://Development/dotfiles").unwrap());
let config = OnePasswordConfig::try_from(&provider_url).unwrap();
assert_eq!(config.default_vault.as_deref(), Some("Development"));
assert_eq!(config.folder_prefix.as_deref(), Some("dotfiles/{key}"));
assert_eq!(config.service_account_token, None);
let provider_url = ProviderUrl::new(
url::Url::parse("onepassword://Development/monosecret/{project}/{profile}/{key}")
.unwrap(),
);
let config = OnePasswordConfig::try_from(&provider_url).unwrap();
assert_eq!(
config.folder_prefix.as_deref(),
Some("monosecret/{project}/{profile}/{key}")
);
}
#[test]
fn op_uri_path_becomes_native_reference_base_path() {
let provider_url =
ProviderUrl::new(url::Url::parse("op+token://Development/dotfiles").unwrap());
let config = OnePasswordConfig::try_from(&provider_url).unwrap();
let provider = OnePasswordProvider::new(config.clone());
assert!(config.native_references);
assert_eq!(config.default_vault.as_deref(), Some("Development"));
assert_eq!(config.reference_base_path, vec!["dotfiles"]);
assert_eq!(config.folder_prefix, None);
assert_eq!(provider.uri(), "op://Development/dotfiles");
let op_token_provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
service_account_token: Some("token".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
assert_eq!(op_token_provider.uri(), "op+token://Development/dotfiles");
let legacy_token_provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
service_account_token: Some("token".to_string()),
..OnePasswordConfig::default()
});
assert_eq!(
legacy_token_provider.uri(),
"onepassword+token://Development"
);
let legacy_url =
ProviderUrl::new(url::Url::parse("onepassword://Development/dotfiles").unwrap());
let legacy_config = OnePasswordConfig::try_from(&legacy_url).unwrap();
assert!(!legacy_config.native_references);
assert_eq!(
legacy_config.folder_prefix.as_deref(),
Some("dotfiles/{key}")
);
assert!(legacy_config.reference_base_path.is_empty());
}
#[test]
#[cfg(unix)]
fn op_command_auth_failure_returns_helpful_error() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let op = temp_dir.path().join("op-auth-fails");
fs::write(
&op,
"#!/usr/bin/env sh\nprintf '%s\\n' 'not currently signed in' >&2\nexit 1\n",
)
.expect("write fake op");
let mut permissions = fs::metadata(&op).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&op, permissions).expect("chmod fake op");
let mut provider = OnePasswordProvider::new(OnePasswordConfig::default());
provider.op_command = op.display().to_string();
let err = provider
.execute_op_command(&["item", "list"], None)
.expect_err("auth failures should be rewritten");
insta::assert_snapshot!(err.to_string());
}
#[test]
#[cfg(unix)]
fn op_get_without_request_reads_native_reference_from_uri_base() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let value = provider
.get("dotfiles", "GITHUB_TOKEN", "default")
.expect("read native reference")
.expect("value from fake op");
assert_eq!(value.expose_secret(), "native-root-github-token");
provider
.set(
"dotfiles",
"GITHUB_TOKEN",
&SecretString::new("new-token".into()),
"default",
)
.expect("edit native reference without request");
let batch = provider
.get_batch("dotfiles", &["GITHUB_TOKEN", "MISSING"], "default")
.expect("batch native reads");
assert_eq!(
batch["GITHUB_TOKEN"].expose_secret(),
"native-root-github-token"
);
assert!(!batch.contains_key("MISSING"));
let calls = call_log_with_sorted_tail(&log, 3);
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn op_get_without_item_path_returns_none_and_set_errors() {
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
..OnePasswordConfig::default()
});
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
provider.op_command = op.display().to_string();
let value = provider
.get("dotfiles", "GITHUB_TOKEN", "default")
.expect("short native reference should not hard fail");
assert!(value.is_none());
let err = provider
.native_item_and_assignment(
"GITHUB_TOKEN",
&SecretString::new("new-token".into()),
None,
)
.expect_err("native set needs an item path");
insta::assert_snapshot!(err.to_string());
}
#[test]
#[cfg(unix)]
fn op_get_with_request_propagates_non_missing_errors() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["error".to_string()]),
key: None,
};
let err = provider
.get_with_request("dotfiles", "GITHUB_TOKEN", "default", &request)
.expect_err("permission errors should propagate");
insta::assert_snapshot!(err.to_string());
}
#[test]
#[cfg(unix)]
fn onepassword_set_with_request_uses_key_hint_for_legacy_storage() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: None,
key: Some("STORED_ITEM".to_string()),
};
provider
.set_with_request(
"dotfiles",
"SECRET_NAME",
&SecretString::new("new-token".into()),
"default",
&request,
)
.expect("legacy set with key hint");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
let batch = provider
.get_batch("dotfiles", &["STORED_ITEM"], "default")
.expect("legacy get_batch should still run through item listing");
assert!(batch.is_empty());
}
#[test]
#[cfg(unix)]
fn op_set_with_request_uses_field_without_section_when_path_is_item_only() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["dotfiles".to_string()]),
key: None,
};
provider
.set_with_request(
"dotfiles",
"GITHUB_TOKEN",
&SecretString::new("new-token".into()),
"default",
&request,
)
.expect("edit native field without section");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn op_get_with_request_reads_native_reference() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["forges".to_string()]),
key: None,
};
let value = provider
.get_with_request("dotfiles", "GITHUB_TOKEN", "default", &request)
.expect("read native reference")
.expect("value from fake op");
assert_eq!(value.expose_secret(), "native-github-token");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn op_set_with_request_edits_existing_native_reference() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["forges".to_string()]),
key: None,
};
provider
.set_with_request(
"dotfiles",
"GITHUB_TOKEN",
&SecretString::new("new-token".into()),
"default",
&request,
)
.expect("edit existing native reference");
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!(calls);
}
#[test]
#[cfg(unix)]
fn op_set_with_request_rejects_missing_native_reference() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
native_references: true,
reference_base_path: vec!["dotfiles".to_string()],
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["missing".to_string()]),
key: None,
};
let err = provider
.set_with_request(
"dotfiles",
"GITHUB_TOKEN",
&SecretString::new("new-token".into()),
"default",
&request,
)
.expect_err("missing native reference should not be created");
insta::assert_snapshot!(
"op_set_with_request_missing_native_reference_error",
err.to_string()
);
let calls = fs::read_to_string(log).expect("read fake op call log");
insta::assert_snapshot!("op_set_with_request_missing_native_reference_calls", calls);
}
#[test]
#[cfg(unix)]
fn onepassword_get_with_request_uses_path_item_and_section() {
init_test_tracing();
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let log = temp_dir.path().join("calls.log");
let op = write_fake_op(&temp_dir, &log);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
default_vault: Some("Development".to_string()),
..OnePasswordConfig::default()
});
provider.op_command = op.display().to_string();
let request = SecretRequest {
path: Some(vec!["dotfiles".to_string(), "forges".to_string()]),
key: None,
};
let value = provider
.get_with_request("dotfiles", "GITHUB_TOKEN", "default", &request)
.expect("read provider-relative field")
.expect("value from fake op");
assert_eq!(value.expose_secret(), "from-dotfiles-item");
let calls = fs::read_to_string(&log).expect("read fake op call log");
insta::assert_snapshot!("onepassword_get_with_request_initial_calls", calls);
let missing_request = SecretRequest {
path: Some(vec!["dotfiles".to_string(), "packages".to_string()]),
key: Some("CARGO_REGISTRY_TOKEN".to_string()),
};
let missing_value = provider
.get_with_request(
"dotfiles",
"CARGO_REGISTRY_TOKEN",
"default",
&missing_request,
)
.expect("missing section/field should not be a hard error");
assert!(missing_value.is_none());
let missing_item_request = SecretRequest {
path: Some(vec!["missing-item".to_string(), "forges".to_string()]),
key: Some("GITHUB_TOKEN".to_string()),
};
let missing_item_value = provider
.get_with_request("dotfiles", "GITHUB_TOKEN", "default", &missing_item_request)
.expect("missing item should not be a hard error");
assert!(missing_item_value.is_none());
insta::assert_snapshot!(
"onepassword_get_with_request_all_calls",
fs::read_to_string(&log).unwrap()
);
}
}