use crate::provider::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env};
use crate::{Result, SecretSpecError};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct OnePasswordItem {
id: Option<String>,
fields: Vec<OnePasswordField>,
}
#[derive(Debug, Deserialize)]
struct OnePasswordField {
id: String,
#[serde(rename = "type")]
field_type: String,
label: Option<String>,
value: 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)]
pub struct SecretReference {
pub item: String,
pub section: Option<String>,
pub field: String,
}
#[derive(Debug, Clone)]
struct BatchRef {
uri: String,
vault: String,
item: String,
}
#[derive(Debug)]
struct InjectTemplate {
input: String,
frames: Vec<(String, String)>,
}
impl InjectTemplate {
fn new(reference_uris: &[String], nonce: &str) -> Self {
let mut input = String::new();
let mut frames = Vec::with_capacity(reference_uris.len());
for (index, reference_uri) in reference_uris.iter().enumerate() {
let start = format!("__SECRETSPEC_OP_{nonce}_{index}_START__");
let end = format!("__SECRETSPEC_OP_{nonce}_{index}_END__");
input.push_str(&start);
input.push_str("{{ ");
input.push_str(reference_uri);
input.push_str(" }}");
input.push_str(&end);
frames.push((start, end));
}
Self { input, frames }
}
fn parse(&self, output: &str) -> Result<Vec<String>> {
for (start, end) in &self.frames {
if output.matches(start).count() != 1 || output.matches(end).count() != 1 {
return Err(Self::malformed_output());
}
}
let mut remaining = output;
let mut values = Vec::with_capacity(self.frames.len());
for (start, end) in &self.frames {
let Some(after_start) = remaining.strip_prefix(start) else {
return Err(Self::malformed_output());
};
let Some((value, after_end)) = after_start.split_once(end) else {
return Err(Self::malformed_output());
};
values.push(value.to_string());
remaining = after_end;
}
if !matches!(remaining, "" | "\n" | "\r\n") {
return Err(Self::malformed_output());
}
Ok(values)
}
fn malformed_output() -> SecretSpecError {
SecretSpecError::ProviderOperationFailed(
"1Password CLI returned malformed output from 'op inject'".to_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>,
}
impl TryFrom<&ProviderUrl> for OnePasswordConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
match scheme {
"1password" => {
return Err(SecretSpecError::ProviderOperationFailed(
"Invalid scheme '1password'. Use 'onepassword' instead (e.g., onepassword://vault)".to_string()
));
}
"onepassword" | "onepassword+token" | "op" => {}
_ => {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for OnePassword provider",
scheme
)));
}
}
if scheme == "onepassword+token" && (!url.username().is_empty() || url.password().is_some())
{
return Err(SecretSpecError::ProviderOperationFailed(
"onepassword+token:// no longer accepts the service account token in the \
URI, because a URI reaches committed manifests, shell history, and CI \
logs. Keep the scheme without the token \
(`onepassword+token://<vault>`) and supply the token as the \
`service_account_token` provider credential (`secretspec config provider \
login <alias>`, or `credentials = { service_account_token = \"keyring\" }` \
on the alias), or set OP_SERVICE_ACCOUNT_TOKEN. See \
https://secretspec.dev/providers/onepassword/#provider-credentials"
.to_string(),
));
}
let mut config = Self::default();
if let Some(host) = url.host()
&& host != "localhost"
{
let username = url.username();
if !username.is_empty() {
config.account = Some(username);
config.default_vault = Some(host);
} else {
config.default_vault = Some(host);
}
}
let path = url.path();
let path = path.trim_matches('/');
if !path.is_empty() || scheme == "op" {
let vault = config.default_vault.as_deref().unwrap_or("<vault>");
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let hint = match segments.as_slice() {
[item, field] => {
crate::config::ref_table_hint(Some(vault), item, None, Some(field))
}
[item, section, field] => {
crate::config::ref_table_hint(Some(vault), item, Some(section), Some(field))
}
_ => crate::config::ref_table_hint(Some(vault), "<item>", None, Some("<field>")),
};
return Err(SecretSpecError::ProviderOperationFailed(format!(
"1Password items are addressed with a secret's `ref`, not in the provider URI: \
use providers = [\"onepassword://{vault}\"] with {hint}"
)));
}
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:// scheme\n \
- Manual signin: run 'eval $(op signin)' (session expires after 30 minutes of inactivity)";
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,
credentials: ProviderCredentials,
#[cfg(test)]
command_override: Option<std::sync::Arc<TestOpCommandOverride>>,
}
#[cfg(test)]
type TestOpCommandOverride =
dyn Fn(&Command, Option<&str>) -> Result<String> + Send + Sync + 'static;
const SERVICE_ACCOUNT_TOKEN: &str = "service_account_token";
const OP_SERVICE_ACCOUNT_TOKEN_ENV: &str = "OP_SERVICE_ACCOUNT_TOKEN";
crate::register_provider! {
struct: OnePasswordProvider,
config: OnePasswordConfig,
name: "onepassword",
description: "OnePassword password manager",
schemes: ["onepassword", "onepassword+token", "op"],
examples: ["onepassword://vault", "onepassword://work@Production", "onepassword+token://vault"],
credential_names: [SERVICE_ACCOUNT_TOKEN],
preflight: check_auth,
}
impl OnePasswordProvider {
pub fn new(config: OnePasswordConfig) -> Self {
let op_command = std::env::var("SECRETSPEC_OPCLI_PATH").unwrap_or_else(|_| {
if is_wsl2() {
"op.exe".to_string()
} else {
"op".to_string()
}
});
Self {
config,
op_command,
credentials: ProviderCredentials::new(),
#[cfg(test)]
command_override: None,
}
}
fn effective_service_account_token(&self) -> Option<String> {
self.config.service_account_token.clone().or_else(|| {
credential_or_env(
&self.credentials,
SERVICE_ACCOUNT_TOKEN,
OP_SERVICE_ACCOUNT_TOKEN_ENV,
)
})
}
fn execute_op_command(&self, args: &[&str], stdin_data: Option<&str>) -> Result<String> {
use std::io::Write;
use std::process::Stdio;
let mut cmd = Command::new(&self.op_command);
strip_op_session_env(&mut cmd);
if let Some(token) = self.effective_service_account_token() {
cmd.env(OP_SERVICE_ACCOUNT_TOKEN_ENV, token);
}
if let Some(account) = &self.config.account {
cmd.arg("--account").arg(account);
}
cmd.args(args);
#[cfg(test)]
if let Some(command_override) = &self.command_override {
return command_override(&cmd, stdin_data);
}
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(SecretSpecError::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(SecretSpecError::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 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")
{
return Err(SecretSpecError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
));
}
return Err(SecretSpecError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"1Password CLI returned non-UTF-8 output: {}",
crate::error::display_error_chain(&e)
))
})
}
fn is_authenticated(&self) -> Result<bool> {
match self.execute_op_command(&["vault", "list", "--format", "json"], None) {
Ok(_) => Ok(true),
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("authentication required") || msg.contains("no account found") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
fn get_vault_name(&self) -> String {
self.config
.default_vault
.clone()
.unwrap_or_else(|| "Private".to_string())
}
fn operation_coordinates(&self, addr: Address<'_>) -> Result<crate::config::NativeAddress> {
let mut coords = self.resolve_coords(addr)?.into_owned();
if coords.vault.is_none() {
coords.vault = Some(self.get_vault_name());
}
Ok(coords)
}
fn reference_uri(vault: &str, reference: &SecretReference) -> String {
match &reference.section {
Some(section) => format!(
"op://{}/{}/{}/{}",
vault, reference.item, section, reference.field
),
None => format!("op://{}/{}/{}", vault, reference.item, reference.field),
}
}
fn read_reference(
&self,
vault: &str,
reference: &SecretReference,
) -> Result<Option<SecretString>> {
self.read_reference_uri(&Self::reference_uri(vault, reference))
}
fn read_reference_uri(&self, reference_uri: &str) -> Result<Option<SecretString>> {
match self.execute_op_command(&["read", "--no-newline", reference_uri], None) {
Ok(output) => Ok(Some(SecretString::new(output.into()))),
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("isn't an item") || msg.contains("doesn't have a field") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
fn read_reference_uris(&self, refs: &[BatchRef]) -> Result<Vec<Option<SecretString>>> {
if refs.is_empty() {
return Ok(Vec::new());
}
if refs.len() == 1 {
return Ok(vec![self.read_reference_uri(&refs[0].uri)?]);
}
let uris: Vec<String> = refs.iter().map(|r| r.uri.clone()).collect();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let template = InjectTemplate::new(&uris, &nonce);
match self.execute_op_command(&["inject"], Some(&template.input)) {
Ok(output) => template.parse(&output).map(|values| {
values
.into_iter()
.map(|value| Some(SecretString::new(value.into())))
.collect()
}),
Err(error) => {
if !inject_error_is_recoverable(&error) {
return Err(error);
}
self.recover_reference_uris(refs)
}
}
}
fn recover_reference_uris(&self, refs: &[BatchRef]) -> Result<Vec<Option<SecretString>>> {
let Some(retained_flags) = self.flag_refs_with_existing_items(refs)? else {
return self.read_uris_with_fallback(refs);
};
if retained_flags.iter().all(|&retained| retained) {
return self.read_uris_with_fallback(refs);
}
let retained: Vec<&BatchRef> = refs
.iter()
.zip(&retained_flags)
.filter_map(|(r, &keep)| keep.then_some(r))
.collect();
let retained_values = match retained.len() {
0 => Vec::new(),
1 => vec![self.read_reference_uri(&retained[0].uri)?],
_ => {
let uris: Vec<String> = retained.iter().map(|r| r.uri.clone()).collect();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let template = InjectTemplate::new(&uris, &nonce);
match self.execute_op_command(&["inject"], Some(&template.input)) {
Ok(output) => template
.parse(&output)?
.into_iter()
.map(|value| Some(SecretString::new(value.into())))
.collect(),
Err(error) => {
if !inject_error_is_recoverable(&error) {
return Err(error);
}
let retained_refs: Vec<BatchRef> =
retained.iter().map(|r| (*r).clone()).collect();
self.read_uris_with_fallback(&retained_refs)?
}
}
}
};
let mut retained_iter = retained_values.into_iter();
Ok(retained_flags
.into_iter()
.map(|keep| {
if keep {
retained_iter.next().flatten()
} else {
None
}
})
.collect())
}
fn read_uris_with_fallback(&self, refs: &[BatchRef]) -> Result<Vec<Option<SecretString>>> {
super::map_concurrently(refs, super::get_each_concurrency(), |r| {
self.read_reference_uri(&r.uri)
})
.into_iter()
.collect()
}
fn flag_refs_with_existing_items(&self, refs: &[BatchRef]) -> Result<Option<Vec<bool>>> {
use std::collections::{HashMap, HashSet};
#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}
let vaults: HashSet<&str> = refs.iter().map(|r| r.vault.as_str()).collect();
let mut known: HashMap<&str, (HashSet<String>, HashSet<String>)> = HashMap::new();
for vault in vaults {
let output = match self.execute_op_command(
&[
"item",
"list",
"--vault",
vault,
"--include-archive",
"--format",
"json",
],
None,
) {
Ok(output) => output,
Err(error) if inject_error_is_recoverable(&error) => return Ok(None),
Err(error) => return Err(error),
};
let items: Vec<ListItem> = match serde_json::from_str(&output) {
Ok(items) => items,
Err(_) => return Ok(None),
};
let mut ids = HashSet::new();
let mut titles = HashSet::new();
for entry in items {
ids.insert(entry.id);
titles.insert(entry.title.trim().to_lowercase());
}
known.insert(vault, (ids, titles));
}
Ok(Some(
refs.iter()
.map(|r| {
let (ids, titles) = &known[r.vault.as_str()];
ids.contains(&r.item) || titles.contains(&r.item.trim().to_lowercase())
})
.collect(),
))
}
fn set_reference(
&self,
vault: &str,
reference: &SecretReference,
value: &SecretString,
) -> Result<()> {
let assignment = format!(
"{}={}",
Self::assignment_target(reference),
value.expose_secret()
);
let args = vec![
"item",
"edit",
&reference.item,
"--vault",
vault,
&assignment,
];
self.execute_op_command(&args, None)?;
Ok(())
}
fn native_reference(
&self,
native: &crate::config::NativeAddress,
) -> Result<(String, Option<SecretReference>)> {
let vault = native
.vault
.clone()
.unwrap_or_else(|| self.get_vault_name());
let reference = match &native.field {
Some(field) => Some(SecretReference {
item: native.item.clone(),
section: native.section.clone(),
field: field.clone(),
}),
None => {
if native.section.is_some() {
return Err(SecretSpecError::ProviderOperationFailed(
"onepassword references with a `section` also need a `field`".to_string(),
));
}
None
}
};
Ok((vault, reference))
}
fn read_item(&self, vault: &str, item_name: &str) -> Result<Option<SecretString>> {
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(SecretSpecError::ProviderOperationFailed(msg)) if msg.contains("isn't an item") => {
Ok(None)
}
Err(SecretSpecError::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 assignment_target(reference: &SecretReference) -> String {
let escape = |s: &str| s.replace('.', "\\.");
match &reference.section {
Some(section) => format!("{}.{}", escape(section), escape(&reference.field)),
None => escape(&reference.field),
}
}
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("secretspec/{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(&self, output: &str) -> Result<Option<SecretString>> {
let item: OnePasswordItem = serde_json::from_str(output)?;
Ok(Self::extract_value(&item))
}
fn extract_value(item: &OnePasswordItem) -> Option<SecretString> {
for field in &item.fields {
if field.label.as_deref() == Some("value") {
return 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 field
.value
.as_ref()
.map(|v| SecretString::new(v.clone().into()));
}
}
None
}
}
const AUTH_ERROR_PATTERNS: &[&str] = &[
"authentication required",
"authorization prompt",
"error initializing client",
];
fn inject_error_is_recoverable(error: &SecretSpecError) -> bool {
let SecretSpecError::ProviderOperationFailed(message) = error else {
return true;
};
if message == OP_NOT_INSTALLED_HELP || message == AUTH_REQUIRED_HELP {
return false;
}
!message.lines().any(|line| {
let Some(diagnostic) = op_error_diagnostic(line) else {
return false;
};
let diagnostic = diagnostic.to_ascii_lowercase();
AUTH_ERROR_PATTERNS
.iter()
.any(|pattern| diagnostic.starts_with(pattern))
})
}
fn op_error_diagnostic(line: &str) -> Option<&str> {
let line = line.trim_start();
let diagnostic = line.strip_prefix("[ERROR]")?.trim_start();
let mut parts = diagnostic.splitn(3, char::is_whitespace);
let Some(date) = parts.next() else {
return Some(diagnostic);
};
let Some(time) = parts.next() else {
return Some(diagnostic);
};
let Some(message) = parts.next() else {
return Some(diagnostic);
};
let is_date = date.split('/').count() == 3
&& date.split('/').all(|component| {
!component.is_empty() && component.chars().all(|c| c.is_ascii_digit())
});
let is_time = time.split(':').count() == 3
&& time.split(':').all(|component| {
!component.is_empty() && component.chars().all(|c| c.is_ascii_digit())
});
if is_date && is_time {
Some(message.trim_start())
} else {
Some(diagnostic)
}
}
impl OnePasswordProvider {
pub(crate) fn check_auth(&self) -> Result<()> {
match self.is_authenticated() {
Ok(true) => Ok(()),
Ok(false) => Err(SecretSpecError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
)),
Err(e) => Err(e),
}
}
}
impl Provider for OnePasswordProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: self.format_item_name(project, key, profile),
vault: Some(self.get_vault_name()),
..Default::default()
})
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field", "vault", "section"]
}
fn entry_coordinates<'a>(
&self,
addr: Address<'a>,
) -> Result<std::borrow::Cow<'a, crate::config::NativeAddress>> {
let mut coords = self.operation_coordinates(addr)?;
if coords.field.is_none() && coords.section.is_none() {
coords.field = Some("value".to_string());
}
Ok(std::borrow::Cow::Owned(coords))
}
fn with_credentials(&mut self, credentials: ProviderCredentials) {
self.credentials = credentials;
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn auth_scope_key(&self) -> Option<String> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.effective_service_account_token().hash(&mut hasher);
let token_scope = hasher.finish();
Some(format!(
"{:?}",
(&self.config.account, token_scope, &self.op_command)
))
}
fn uri(&self) -> String {
let scheme = if self.config.service_account_token.is_some() {
"onepassword+token"
} else {
"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));
}
}
uri
}
fn entry_container_identity(&self) -> String {
match &self.config.account {
Some(account) => format!("onepassword://{}@", ProviderUrl::encode(account)),
None => "onepassword://".to_string(),
}
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.operation_coordinates(addr)?;
let (vault, reference) = self.native_reference(&coords)?;
match reference {
Some(reference) => self.read_reference(&vault, &reference),
None => self.read_item(&vault, &coords.item),
}
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let (project, profile, key) = match addr {
Address::Native(native) => {
let coords = self.entry_coordinates(addr)?;
let (vault, reference) = self.native_reference(&coords)?;
let reference = reference.unwrap_or_else(|| SecretReference {
item: native.item.clone(),
section: None,
field: "value".to_string(),
});
return self.set_reference(&vault, &reference, value);
}
Address::Convention {
project,
profile,
key,
} => (project, profile, key),
};
let vault = self.get_vault_name();
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 get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
if requests.is_empty() {
return Ok(HashMap::new());
}
let mut whole_items: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut field_ref_indices: HashMap<String, usize> = HashMap::new();
let mut field_refs: Vec<(BatchRef, Vec<String>)> = Vec::new();
for (name, addr) in requests {
let coords = self.operation_coordinates(*addr)?;
let (vault, reference) = self.native_reference(&coords)?;
match reference {
Some(reference) => {
let reference_uri = Self::reference_uri(&vault, &reference);
if let Some(index) = field_ref_indices.get(&reference_uri) {
field_refs[*index].1.push(name.to_string());
} else {
field_ref_indices.insert(reference_uri.clone(), field_refs.len());
let batch_ref = BatchRef {
uri: reference_uri,
vault: vault.clone(),
item: reference.item.clone(),
};
field_refs.push((batch_ref, vec![name.to_string()]));
}
}
None => whole_items
.entry(vault)
.or_default()
.push((name.to_string(), coords.item.clone())),
}
}
let mut results = HashMap::new();
for (vault, items) in whole_items {
results.extend(self.get_items_batch(&vault, items)?);
}
let refs: Vec<BatchRef> = field_refs.iter().map(|(r, _)| r.clone()).collect();
let values = self.read_reference_uris(&refs)?;
for ((_, names), value) in field_refs.into_iter().zip(values) {
if let Some(value) = value {
for name in names {
results.insert(name, value.clone());
}
}
}
Ok(results)
}
}
impl OnePasswordProvider {
fn get_items_batch(
&self,
vault: &str,
items: Vec<(String, String)>,
) -> Result<HashMap<String, SecretString>> {
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 listed: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
let item_map: HashMap<String, String> = listed
.into_iter()
.map(|item| (item.title, item.id))
.collect();
let mut fetch_indices: HashMap<String, usize> = HashMap::new();
let mut to_fetch: Vec<(String, Vec<String>)> = Vec::new();
for (name, title) in items {
let Some(item_id) = item_map.get(&title) else {
continue;
};
if let Some(index) = fetch_indices.get(item_id) {
to_fetch[*index].1.push(name);
} else {
fetch_indices.insert(item_id.clone(), to_fetch.len());
to_fetch.push((item_id.clone(), vec![name]));
}
}
if to_fetch.is_empty() {
return Ok(HashMap::new());
}
#[derive(Serialize)]
struct ItemSpecifier<'a> {
id: &'a str,
}
let input = serde_json::to_string(
&to_fetch
.iter()
.map(|(item_id, _)| ItemSpecifier { id: item_id })
.collect::<Vec<_>>(),
)?;
let output = self.execute_op_command(
&["item", "get", "-", "--vault", vault, "--format", "json"],
Some(&input),
)?;
let fetched: Vec<OnePasswordItem> =
match serde_json::from_str::<Vec<OnePasswordItem>>(&output) {
Ok(items) => items,
Err(_) => serde_json::Deserializer::from_str(&output)
.into_iter::<OnePasswordItem>()
.collect::<std::result::Result<_, _>>()
.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"1Password CLI returned invalid batched item JSON: {error}"
))
})?,
};
let expected_count = to_fetch.len();
let mut names_by_id: HashMap<String, Vec<String>> = to_fetch.into_iter().collect();
let mut results = HashMap::new();
for item in fetched {
let item_id = item.id.as_deref().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"1Password CLI batch response omitted an item ID".to_string(),
)
})?;
let names = names_by_id.remove(item_id).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"1Password CLI batch response contained an unexpected item".to_string(),
)
})?;
if let Some(value) = Self::extract_value(&item) {
for name in names {
results.insert(name, value.clone());
}
}
}
if !names_by_id.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"1Password CLI returned {} of {expected_count} requested items",
expected_count - names_by_id.len()
)));
}
Ok(results)
}
}
impl Default for OnePasswordProvider {
fn default() -> Self {
Self::new(OnePasswordConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
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 same_entries_treats_an_implicit_vault_as_the_configured_default() {
let provider = OnePasswordProvider::new(config("onepassword://Production"));
let implicit = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let explicit = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("credential".to_string()),
vault: Some("Production".to_string()),
..Default::default()
};
assert!(
provider
.same_entries(
Address::Native(&implicit),
&provider,
Address::Native(&explicit),
)
.unwrap(),
"addresses that operations send to one 1Password field must compare equal"
);
}
#[test]
fn same_entries_treats_an_implicit_field_as_the_value_field() {
let provider = OnePasswordProvider::new(config("onepassword://Production"));
let implicit = crate::config::NativeAddress {
item: "API Key".to_string(),
..Default::default()
};
let explicit = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("value".to_string()),
..Default::default()
};
assert!(
provider
.same_entries(
Address::Native(&implicit),
&provider,
Address::Native(&explicit),
)
.unwrap()
);
}
#[test]
fn same_entries_uses_explicit_vaults_instead_of_provider_defaults() {
let production = OnePasswordProvider::new(config("onepassword://work@Production"));
let development = OnePasswordProvider::new(config("onepassword://work@Development"));
let address = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("credential".to_string()),
vault: Some("Shared".to_string()),
..Default::default()
};
assert!(
production
.same_entries(
Address::Native(&address),
&development,
Address::Native(&address),
)
.unwrap()
);
}
#[test]
fn try_from_token_scheme_rejects_a_token_in_the_uri() {
for source in [
"onepassword+token://ops_tok@Private",
"onepassword+token://acct:ops_tok@Private",
] {
let Err(error) = Box::<dyn crate::provider::Provider>::try_from(source) else {
panic!("{source} was accepted");
};
let message = error.to_string();
assert!(
message.contains("service_account_token"),
"{source}: {message}"
);
assert!(!message.contains("ops_tok"), "{source}: {message}");
}
let message = config_err("onepassword+token://ops_tok@Private").to_string();
assert!(message.contains("OP_SERVICE_ACCOUNT_TOKEN"), "{message}");
assert!(message.contains("onepassword+token://<vault>"), "{message}");
}
#[test]
fn try_from_token_scheme_without_a_token_selects_the_vault() {
let c = config("onepassword+token://Private");
assert_eq!(c.default_vault.as_deref(), Some("Private"));
assert_eq!(c.service_account_token, None);
assert_eq!(c.account, None);
}
#[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(), "Private");
let configured = OnePasswordProvider::new(config("onepassword://Production"));
assert_eq!(configured.get_vault_name(), "Production");
}
#[test]
fn format_item_name_default_and_custom() {
let default = OnePasswordProvider::new(OnePasswordConfig::default());
assert_eq!(
default.format_item_name("proj", "KEY", "prod"),
"secretspec/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 mut config = config("onepassword+token://Private");
config.service_account_token = Some("ops_secret_tok".to_string());
let provider = OnePasswordProvider::new(config);
let uri = provider.uri();
assert_eq!(uri, "onepassword+token://Private");
assert!(!uri.contains("ops_secret_tok"));
}
fn config_err(s: &str) -> SecretSpecError {
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap_err()
}
#[test]
fn item_paths_are_rejected_with_ref_hint() {
let err = config_err("op://Infra/db/password");
assert!(
err.to_string()
.contains("ref = { vault = \"Infra\", item = \"db\", field = \"password\" }"),
"{err}"
);
let err = config_err("op://Infra");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
let err = config_err("onepassword://vault/Production");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
let err = config_err("op://Infra/a/b/c/d");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
}
#[test]
fn assignment_target_escapes_dots() {
let reference = SecretReference {
item: "db".to_string(),
section: Some("api.keys".to_string()),
field: "connection.url".to_string(),
};
assert_eq!(
OnePasswordProvider::assignment_target(&reference),
"api\\.keys.connection\\.url"
);
let reference = SecretReference {
section: None,
..reference
};
assert_eq!(
OnePasswordProvider::assignment_target(&reference),
"connection\\.url"
);
}
#[test]
fn pasted_reference_hint_preserves_spaces() {
let Err(err) = Box::<dyn Provider>::try_from("op://Prod Vault/My Item/field") else {
panic!("op:// provider spec must be rejected");
};
assert!(
err.to_string().contains(
"ref = { vault = \"Prod Vault\", item = \"My Item\", field = \"field\" }"
),
"{err}"
);
}
#[test]
fn native_address_maps_coordinates_with_vault_override() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
field: Some("password".into()),
section: Some("api".into()),
vault: Some("Production".into()),
..Default::default()
};
let (vault, reference) = provider.native_reference(&addr).unwrap();
assert_eq!(vault, "Production");
let reference = reference.expect("field-addressed reference");
assert_eq!(
OnePasswordProvider::reference_uri(&vault, &reference),
"op://Production/db/api/password"
);
}
#[test]
fn native_address_vault_defaults_to_store_vault() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
field: Some("password".into()),
..Default::default()
};
let (vault, _) = provider.native_reference(&addr).unwrap();
assert_eq!(vault, "Personal");
}
#[test]
fn native_address_without_field_names_the_whole_item() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "My API Item".into(),
..Default::default()
};
let (_, reference) = provider.native_reference(&addr).unwrap();
assert!(reference.is_none());
}
#[test]
fn native_address_rejects_version() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
version: Some("3".into()),
..Default::default()
};
let err = provider.resolve_coords(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`version`"), "{err}");
}
#[test]
fn native_address_section_requires_field() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
section: Some("api".into()),
..Default::default()
};
let err = provider.native_reference(&addr).unwrap_err();
assert!(err.to_string().contains("need a `field`"), "{err}");
}
fn command_args(command: &Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn framed_output(template: &InjectTemplate, values: &[&str]) -> String {
let mut output = String::new();
for ((start, end), value) in template.frames.iter().zip(values) {
output.push_str(start);
output.push_str(value);
output.push_str(end);
}
output
}
fn secret_matches(results: &HashMap<String, SecretString>, name: &str, expected: &str) -> bool {
results
.get(name)
.is_some_and(|value| value.expose_secret() == expected)
}
#[test]
fn inject_template_round_trips_arbitrary_utf8_values() {
let references: Vec<String> = (0..7)
.map(|index| format!("op://vault/item/{index}"))
.collect();
let template = InjectTemplate::new(&references, "deterministic-nonce");
let values = [
"contains=equals",
"\"quoted\"",
r"back\slash",
"Zażółć gęślą jaźń 🔐",
"",
" spaces stay ",
"first line\nsecond line\nthird line",
];
for reference in &references {
let expression = format!("{{{{ {reference} }}}}");
assert_eq!(template.input.matches(&expression).count(), 1);
}
assert!(
values
.iter()
.filter(|value| !value.is_empty())
.all(|value| !template.input.contains(value))
);
let parsed = template.parse(&framed_output(&template, &values)).unwrap();
assert!(
parsed
.iter()
.zip(values)
.all(|(actual, expected)| actual == expected)
);
}
#[test]
fn inject_parser_accepts_cli_trailing_newline_without_trimming_values() {
let references = vec!["op://vault/item/one".to_string()];
let template = InjectTemplate::new(&references, "deterministic-nonce");
let value = " secret whitespace stays \n";
let output = format!("{}\n", framed_output(&template, &[value]));
assert_eq!(template.parse(&output).unwrap(), [value]);
}
#[test]
fn inject_parser_rejects_malformed_output_without_echoing_it() {
let references = vec![
"op://vault/item/one".to_string(),
"op://vault/item/two".to_string(),
];
let template = InjectTemplate::new(&references, "deterministic-nonce");
let valid = framed_output(&template, &["first", "second"]);
let (first_start, first_end) = &template.frames[0];
let (second_start, second_end) = &template.frames[1];
let sensitive = "DO_NOT_ECHO_PLAINTEXT";
let malformed = [
valid.trim_end_matches(second_end).to_string(),
format!("{valid}{first_start}{first_end}"),
format!("{second_start}second{second_end}{first_start}first{first_end}"),
format!("unexpected{valid}"),
format!("{valid}\n\n"),
format!("{valid} \n"),
format!(
"{first_start}{sensitive}{first_end}{first_end}{second_start}second{second_end}"
),
];
for output in malformed {
let error = template.parse(&output).unwrap_err().to_string();
assert_eq!(
error,
"Provider operation failed: 1Password CLI returned malformed output from 'op inject'"
);
assert!(!error.contains(sensitive));
assert!(!error.contains(&output));
}
}
#[test]
fn multiple_field_refs_use_one_inject_and_fan_out_duplicates() {
use std::sync::{Arc, Mutex};
#[derive(Debug)]
struct ObservedCall {
args: Vec<String>,
template: String,
token_is_set: bool,
}
let calls = Arc::new(Mutex::new(Vec::<ObservedCall>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(OnePasswordConfig {
account: Some("work".to_string()),
default_vault: Some("Personal Vault".to_string()),
service_account_token: Some("ops_test_token".to_string()),
..Default::default()
});
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let token_is_set = command.get_envs().any(|(key, value)| {
key == OP_SERVICE_ACCOUNT_TOKEN_ENV
&& value.is_some_and(|value| value == "ops_test_token")
});
let template = stdin.expect("inject stdin").to_string();
observed.lock().unwrap().push(ObservedCall {
args,
template: template.clone(),
token_is_set,
});
Ok(template
.replace(
"{{ op://Personal Vault/API Key/password }}",
"first=\"value\"\\with\nlines 🔐",
)
.replace(
"{{ op://Prod Vault/Database/API Section/client secret }}",
"",
))
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let duplicate = first.clone();
let second = crate::config::NativeAddress {
item: "Database".to_string(),
section: Some("API Section".to_string()),
field: Some("client secret".to_string()),
vault: Some("Prod Vault".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("FIRST_COPY", Address::Native(&duplicate)),
("SECOND", Address::Native(&second)),
])
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].args, ["--account", "work", "inject"]);
assert!(calls[0].token_is_set);
assert_eq!(
calls[0]
.template
.matches("{{ op://Personal Vault/API Key/password }}")
.count(),
1
);
assert_eq!(
calls[0]
.template
.matches("{{ op://Prod Vault/Database/API Section/client secret }}")
.count(),
1
);
assert!(!calls[0].template.contains("first=\"value\""));
assert!(secret_matches(
&results,
"FIRST",
"first=\"value\"\\with\nlines 🔐"
));
assert!(secret_matches(
&results,
"FIRST_COPY",
"first=\"value\"\\with\nlines 🔐"
));
assert!(secret_matches(&results, "SECOND", ""));
}
#[test]
fn one_unique_field_ref_uses_one_read_and_fans_out() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
assert!(stdin.is_none());
observed.lock().unwrap().push(command_args(command));
Ok("single value".to_string())
}));
let address = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&address)),
("SECOND", Address::Native(&address)),
])
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0],
["read", "--no-newline", "op://Personal/API Key/password"]
);
assert!(secret_matches(&results, "FIRST", "single value"));
assert!(secret_matches(&results, "SECOND", "single value"));
}
#[test]
fn inject_failure_falls_back_and_omits_missing_references() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, _stdin| {
let args = command_args(command);
observed.lock().unwrap().push(args.clone());
match args.first().map(String::as_str) {
Some("inject") => Err(SecretSpecError::ProviderOperationFailed(
"one field is missing".to_string(),
)),
Some("item") => Ok(r#"[{"id":"item-id","title":"Item"}]"#.to_string()),
Some("read") if args.last().is_some_and(|arg| arg.ends_with("/present")) => {
Ok("available".to_string())
}
Some("read") => Err(SecretSpecError::ProviderOperationFailed(
"item doesn't have a field with this name".to_string(),
)),
_ => unreachable!("unexpected mocked command"),
}
}));
let present = crate::config::NativeAddress {
item: "Item".to_string(),
field: Some("present".to_string()),
..Default::default()
};
let missing = crate::config::NativeAddress {
item: "Item".to_string(),
field: Some("missing".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("PRESENT", Address::Native(&present)),
("MISSING", Address::Native(&missing)),
("MISSING_COPY", Address::Native(&missing)),
])
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 4, "inject + one vault listing + two reads");
assert_eq!(calls[0], ["inject"]);
assert_eq!(calls.iter().filter(|args| args[0] == "item").count(), 1);
assert_eq!(calls.iter().filter(|args| args[0] == "read").count(), 2);
assert!(secret_matches(&results, "PRESENT", "available"));
assert!(!results.contains_key("MISSING"));
assert!(!results.contains_key("MISSING_COPY"));
}
#[test]
fn inject_failure_fallback_preserves_bounded_concurrency() {
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
let _lock = crate::tests::scrub_resolution_env();
let _concurrency =
crate::tests::EnvVarGuard::set(super::super::GET_EACH_CONCURRENCY_ENV, "3");
let current = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let reads = Arc::new(AtomicUsize::new(0));
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new({
let current = Arc::clone(¤t);
let peak = Arc::clone(&peak);
let reads = Arc::clone(&reads);
move |command, _stdin| {
let args = command_args(command);
if args.first().is_some_and(|arg| arg == "inject") {
return Err(SecretSpecError::ProviderOperationFailed(
"one field is missing".to_string(),
));
}
if args.first().is_some_and(|arg| arg == "item") {
return Ok(r#"[{"id":"item-id","title":"Item"}]"#.to_string());
}
assert_eq!(args.first().map(String::as_str), Some("read"));
reads.fetch_add(1, Ordering::SeqCst);
let active = current.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(active, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(80));
current.fetch_sub(1, Ordering::SeqCst);
Ok(args.last().expect("reference URI").clone())
}
}));
let refs: Vec<BatchRef> = (0..10)
.map(|index| BatchRef {
uri: format!("op://Personal/Item/field-{index}"),
vault: "Personal".to_string(),
item: "Item".to_string(),
})
.collect();
let values = provider.read_reference_uris(&refs).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), refs.len());
assert!(
peak.load(Ordering::SeqCst) <= 3,
"fallback exceeded the configured concurrency cap"
);
assert!(
peak.load(Ordering::SeqCst) >= 2,
"fallback unexpectedly processed every reference serially"
);
assert!(values.iter().zip(&refs).all(|(value, r)| {
value
.as_ref()
.is_some_and(|value| value.expose_secret() == r.uri)
}));
}
#[test]
fn auth_failure_on_inject_fails_fast_without_fanout() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, _stdin| {
observed.lock().unwrap().push(command_args(command));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] 2026/08/14 00:00:00 error initializing client: found no accounts for filter \"x\"".to_string(),
))
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let error = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("SECOND", Address::Native(&second)),
])
.unwrap_err();
assert!(error.to_string().contains("error initializing client"));
assert_eq!(
calls.lock().unwrap().len(),
1,
"auth failure must not retry or fan out"
);
}
#[test]
fn inject_error_classification_separates_auth_from_data() {
let auth_authentication_required =
SecretSpecError::ProviderOperationFailed(AUTH_REQUIRED_HELP.to_string());
let auth_authorization_prompt = SecretSpecError::ProviderOperationFailed(
"[ERROR] 2026/08/14 00:00:00 authorization prompt dismissed, please try again"
.to_string(),
);
let auth_error_initializing_client = SecretSpecError::ProviderOperationFailed(
"[ERROR] 2026/08/14 00:00:00 error initializing client: found no accounts for filter \"x\"".to_string(),
);
let cli_not_installed =
SecretSpecError::ProviderOperationFailed(OP_NOT_INSTALLED_HELP.to_string());
let data = SecretSpecError::ProviderOperationFailed(
"[ERROR] 2026/08/14 00:00:00 could not resolve item UUID for item X: could not find item X in vault abc".to_string(),
);
assert!(!inject_error_is_recoverable(&auth_authentication_required));
assert!(!inject_error_is_recoverable(&auth_authorization_prompt));
assert!(!inject_error_is_recoverable(
&auth_error_initializing_client
));
assert!(!inject_error_is_recoverable(&cli_not_installed));
assert!(inject_error_is_recoverable(&data));
for item in [
"Authentication Required",
"Authorization Prompt",
"Error Initializing Client",
] {
let missing_item = SecretSpecError::ProviderOperationFailed(format!(
"[ERROR] 2026/08/14 00:00:00 could not resolve item UUID for item {item}: could not find item {item} in vault abc"
));
assert!(
inject_error_is_recoverable(&missing_item),
"auth-like item name {item:?} must remain a recoverable data error"
);
}
}
#[test]
fn missing_item_drops_ref_and_retries_batch_once() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let mut log = observed.lock().unwrap();
let call_index = log.len();
log.push((args.clone(), stdin.map(str::to_string)));
drop(log);
match call_index {
0 => {
assert!(args.contains(&"inject".to_string()));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
))
}
1 => {
assert_eq!(
args,
[
"item",
"list",
"--vault",
"Personal",
"--include-archive",
"--format",
"json"
]
);
Ok(
r#"[{"id":"aaa111","title":"API Key"},{"id":"bbb222","title":"Database"}]"#
.to_string(),
)
}
2 => {
let template = stdin.expect("retry inject stdin").to_string();
assert!(args.contains(&"inject".to_string()));
assert!(
!template.contains("Ghost"),
"dropped ref must not be retried"
);
Ok(template
.replace("{{ op://Personal/API Key/password }}", "alpha")
.replace("{{ op://Personal/Database/secret }}", "beta"))
}
_ => panic!("no further op calls expected"),
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
("SECOND", Address::Native(&second)),
])
.unwrap();
assert_eq!(calls.lock().unwrap().len(), 3);
assert!(secret_matches(&results, "FIRST", "alpha"));
assert!(secret_matches(&results, "SECOND", "beta"));
assert!(
!results.contains_key("GHOST"),
"missing item resolves as absent"
);
}
#[test]
fn missing_item_check_is_case_insensitive_and_retains_match() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let mut log = observed.lock().unwrap();
let call_index = log.len();
log.push((args.clone(), stdin.map(str::to_string)));
drop(log);
match call_index {
0 => {
assert!(args.contains(&"inject".to_string()));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
))
}
1 => {
assert_eq!(
args,
[
"item",
"list",
"--vault",
"Personal",
"--include-archive",
"--format",
"json"
]
);
Ok(
r#"[{"id":"aaa111","title":"api key"},{"id":"bbb222","title":"Database"}]"#
.to_string(),
)
}
2 => {
let template = stdin.expect("retry inject stdin").to_string();
assert!(args.contains(&"inject".to_string()));
assert!(
!template.contains("Ghost"),
"dropped ref must not be retried"
);
assert!(
template.contains("{{ op://Personal/API Key/password }}"),
"case-different title match must retain the ref for retry"
);
Ok(template
.replace("{{ op://Personal/API Key/password }}", "alpha")
.replace("{{ op://Personal/Database/secret }}", "beta"))
}
_ => panic!("no further op calls expected"),
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
("SECOND", Address::Native(&second)),
])
.unwrap();
assert_eq!(calls.lock().unwrap().len(), 3);
assert!(secret_matches(&results, "FIRST", "alpha"));
assert!(secret_matches(&results, "SECOND", "beta"));
assert!(
!results.contains_key("GHOST"),
"missing item resolves as absent"
);
}
#[test]
fn multi_vault_recovery_lists_each_vault_once() {
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
observed.lock().unwrap().push(args.clone());
if args.contains(&"inject".to_string()) {
return Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item X: could not find item X in vault abc".to_string(),
));
}
if args.first().map(String::as_str) == Some("item") {
let vault = args.get(3).expect("--vault value").as_str();
let body = match vault {
"Personal" => r#"[{"id":"aaa111","title":"API Key"}]"#,
"Work" => r#"[{"id":"bbb222","title":"Secret"}]"#,
other => panic!("unexpected vault {other}"),
};
return Ok(body.to_string());
}
assert_eq!(args.first().map(String::as_str), Some("read"));
assert!(stdin.is_none());
Ok(format!("value-for-{}", args.last().expect("reference URI")))
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Secret".to_string(),
field: Some("value".to_string()),
vault: Some("Work".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("SECOND", Address::Native(&second)),
])
.unwrap();
let calls = calls.lock().unwrap();
let list_calls: Vec<&Vec<String>> = calls
.iter()
.filter(|args| args.first().map(String::as_str) == Some("item"))
.collect();
assert_eq!(
list_calls.len(),
2,
"one `item list` call per distinct vault"
);
let listed_vaults: HashSet<&str> = list_calls.iter().map(|args| args[3].as_str()).collect();
assert!(listed_vaults.contains("Personal"));
assert!(listed_vaults.contains("Work"));
assert_eq!(
calls
.iter()
.filter(|args| args.first().map(String::as_str) == Some("inject"))
.count(),
1
);
assert_eq!(
calls
.iter()
.filter(|args| args.first().map(String::as_str) == Some("read"))
.count(),
2
);
assert!(secret_matches(
&results,
"FIRST",
"value-for-op://Personal/API Key/password"
));
assert!(secret_matches(
&results,
"SECOND",
"value-for-op://Work/Secret/value"
));
}
#[test]
fn failed_retry_falls_back_to_reads_for_retained_refs_only() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let mut log = observed.lock().unwrap();
let call_index = log.len();
log.push((args.clone(), stdin.map(str::to_string)));
drop(log);
match call_index {
0 => Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
)),
1 => {
assert_eq!(args, ["item", "list", "--vault", "Personal", "--include-archive", "--format", "json"]);
Ok(r#"[{"id":"aaa111","title":"API Key"},{"id":"bbb222","title":"Database"}]"#.to_string())
}
2 => {
assert!(args.contains(&"inject".to_string()));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] item 'Personal/API Key' does not have a field 'password'".to_string(),
))
}
index => {
assert_eq!(args[0], "read", "post-retry recovery must use per-ref reads");
let uri = &args[2];
assert!(!uri.contains("Ghost"), "dropped ref must not be individually read");
assert!(index <= 4, "exactly one read per retained ref");
if uri.contains("API Key") {
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] item Personal/API Key doesn't have a field password".to_string(),
))
} else {
Ok("beta".to_string())
}
}
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
("SECOND", Address::Native(&second)),
])
.unwrap();
assert_eq!(
calls.lock().unwrap().len(),
5,
"inject, list, retry inject, 2 reads"
);
assert!(
!results.contains_key("GHOST"),
"listed-missing ref stays absent, never read"
);
assert!(
!results.contains_key("FIRST"),
"field-miss on read resolves as absent"
);
assert!(secret_matches(&results, "SECOND", "beta"));
}
#[test]
fn failed_item_list_falls_back_to_reads_for_all_refs() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let mut log = observed.lock().unwrap();
let call_index = log.len();
log.push((args.clone(), stdin.map(str::to_string)));
drop(log);
match call_index {
0 => Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
)),
1 => {
assert_eq!(args, ["item", "list", "--vault", "Personal", "--include-archive", "--format", "json"]);
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] vault listing unavailable".to_string(),
))
}
index => {
assert_eq!(args[0], "read", "full fallback must use per-ref reads");
let uri = &args[2];
assert!(index <= 4, "exactly one read per ref, including the dropped one");
if uri.contains("Ghost") {
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] \"Ghost\" isn't an item in this vault".to_string(),
))
} else if uri.contains("API Key") {
Ok("alpha".to_string())
} else {
Ok("beta".to_string())
}
}
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
("SECOND", Address::Native(&second)),
])
.unwrap();
let observed_calls = calls.lock().unwrap();
assert_eq!(observed_calls.len(), 5, "inject, list, 3 reads");
assert!(
observed_calls[2..]
.iter()
.any(|(args, _)| args[2].contains("Ghost")),
"an unresolvable vault listing must still individually read every ref, including Ghost"
);
drop(observed_calls);
assert!(
!results.contains_key("GHOST"),
"missing item resolves as absent"
);
assert!(secret_matches(&results, "FIRST", "alpha"));
assert!(secret_matches(&results, "SECOND", "beta"));
}
#[test]
fn auth_error_on_item_list_fails_fast() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, _stdin| {
let args = command_args(command);
let mut calls = observed.lock().unwrap();
let call_index = calls.len();
calls.push(args.clone());
drop(calls);
match call_index {
0 => Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
)),
1 => {
assert_eq!(args.first().map(String::as_str), Some("item"));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] error initializing client: found no accounts for filter \"x\""
.to_string(),
))
}
_ => panic!("no per-reference reads after an auth failure"),
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let error = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
])
.unwrap_err();
assert!(error.to_string().contains("error initializing client"));
assert_eq!(calls.lock().unwrap().len(), 2, "inject, item list");
}
#[test]
fn auth_error_on_retry_inject_fails_fast() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
let mut log = observed.lock().unwrap();
let call_index = log.len();
log.push((args.clone(), stdin.map(str::to_string)));
drop(log);
match call_index {
0 => Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] could not resolve item UUID for item Ghost: could not find item Ghost in vault abc".to_string(),
)),
1 => {
assert_eq!(args, ["item", "list", "--vault", "Personal", "--include-archive", "--format", "json"]);
Ok(r#"[{"id":"aaa111","title":"API Key"},{"id":"bbb222","title":"Database"}]"#.to_string())
}
2 => {
assert!(args.contains(&"inject".to_string()));
Err(SecretSpecError::ProviderOperationFailed(
"[ERROR] error initializing client: found no accounts for filter \"x\"".to_string(),
))
}
_ => panic!("no calls after auth failure"),
}
}));
let first = crate::config::NativeAddress {
item: "API Key".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let ghost = crate::config::NativeAddress {
item: "Ghost".to_string(),
field: Some("credential".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Database".to_string(),
field: Some("secret".to_string()),
..Default::default()
};
let error = provider
.get_many(&[
("FIRST", Address::Native(&first)),
("GHOST", Address::Native(&ghost)),
("SECOND", Address::Native(&second)),
])
.unwrap_err();
assert!(error.to_string().contains("error initializing client"));
assert_eq!(calls.lock().unwrap().len(), 3);
}
#[test]
fn missing_item_check_matches_by_id() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, _stdin| {
let args = command_args(command);
observed.lock().unwrap().push(args.clone());
assert_eq!(
args,
[
"item",
"list",
"--vault",
"Personal",
"--include-archive",
"--format",
"json"
]
);
Ok(r#"[{"id":"aaa111","title":"Something Else"}]"#.to_string())
}));
let refs = vec![BatchRef {
uri: "op://Personal/aaa111/password".to_string(),
vault: "Personal".to_string(),
item: "aaa111".to_string(),
}];
let flags = provider
.flag_refs_with_existing_items(&refs)
.unwrap()
.unwrap();
assert_eq!(
flags,
[true],
"a ref whose item matches the listing entry's id (not its title) must be retained, not dropped"
);
assert_eq!(calls.lock().unwrap().len(), 1);
}
#[test]
fn mixed_whole_items_and_field_refs_keep_both_batch_paths() {
use std::sync::{Arc, Mutex};
let calls = Arc::new(Mutex::new(Vec::<Vec<String>>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
observed.lock().unwrap().push(args.clone());
match args.as_slice() {
[command, list, ..] if command == "item" && list == "list" => {
Ok(r#"[{"id":"whole-id","title":"Whole Item"}]"#.to_string())
}
[
command,
get,
stdin_arg,
vault_flag,
vault,
format_flag,
format,
] if command == "item"
&& get == "get"
&& stdin_arg == "-"
&& vault_flag == "--vault"
&& vault == "Personal"
&& format_flag == "--format"
&& format == "json" =>
{
assert_eq!(stdin, Some(r#"[{"id":"whole-id"}]"#));
Ok(r#"{"id":"whole-id","fields":[{"id":"value","type":"STRING","label":"value","value":"whole value"}]}"#.to_string())
}
[command] if command == "inject" => Ok(stdin
.expect("inject stdin")
.replace("{{ op://Personal/Field One/password }}", "field one")
.replace("{{ op://Personal/Field Two/token }}", "field two")),
_ => unreachable!("unexpected mocked command"),
}
}));
let whole = crate::config::NativeAddress {
item: "Whole Item".to_string(),
..Default::default()
};
let first = crate::config::NativeAddress {
item: "Field One".to_string(),
field: Some("password".to_string()),
..Default::default()
};
let second = crate::config::NativeAddress {
item: "Field Two".to_string(),
field: Some("token".to_string()),
..Default::default()
};
let results = provider
.get_many(&[
("WHOLE", Address::Native(&whole)),
("FIRST", Address::Native(&first)),
("SECOND", Address::Native(&second)),
])
.unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.iter().filter(|args| args[0] == "inject").count(), 1);
assert_eq!(
calls
.iter()
.filter(|args| args.starts_with(&["item".to_string(), "list".to_string()]))
.count(),
1
);
assert_eq!(
calls
.iter()
.filter(|args| args.starts_with(&["item".to_string(), "get".to_string()]))
.count(),
1
);
assert!(secret_matches(&results, "WHOLE", "whole value"));
assert!(secret_matches(&results, "FIRST", "field one"));
assert!(secret_matches(&results, "SECOND", "field two"));
}
#[test]
fn whole_item_batch_uses_one_get_process_and_maps_results_by_id() {
use std::sync::{Arc, Mutex};
let listed = serde_json::Value::Array(
(0..10)
.map(|index| {
serde_json::json!({
"id": format!("item-{index}"),
"title": format!("Secret {index}"),
})
})
.collect(),
)
.to_string();
let fetched = (0..10)
.rev()
.map(|index| {
serde_json::json!({
"id": format!("item-{index}"),
"fields": [{
"id": "value",
"type": "STRING",
"label": "value",
"value": format!("value-{index}"),
}],
})
.to_string()
})
.collect::<String>();
let calls = Arc::new(Mutex::new(Vec::<(Vec<String>, Option<String>)>::new()));
let observed = Arc::clone(&calls);
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(move |command, stdin| {
let args = command_args(command);
observed
.lock()
.unwrap()
.push((args.clone(), stdin.map(str::to_string)));
match args.as_slice() {
[command, list, ..] if command == "item" && list == "list" => {
assert!(stdin.is_none());
Ok(listed.clone())
}
[
command,
get,
stdin_arg,
vault_flag,
vault,
format_flag,
format,
] if command == "item"
&& get == "get"
&& stdin_arg == "-"
&& vault_flag == "--vault"
&& vault == "Personal"
&& format_flag == "--format"
&& format == "json" =>
{
assert!(stdin.is_some());
Ok(fetched.clone())
}
_ => unreachable!("unexpected mocked command"),
}
}));
let names: Vec<String> = (0..10).map(|index| format!("SECRET_{index}")).collect();
let addresses: Vec<crate::config::NativeAddress> = (0..10)
.map(|index| crate::config::NativeAddress {
item: format!("Secret {index}"),
..Default::default()
})
.collect();
let requests: Vec<(&str, Address<'_>)> = names
.iter()
.zip(&addresses)
.map(|(name, address)| (name.as_str(), Address::Native(address)))
.collect();
let results = provider.get_many(&requests).unwrap();
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2, "one list process and one get process");
assert_eq!(
calls[1].0,
[
"item", "get", "-", "--vault", "Personal", "--format", "json"
]
);
let batch_input = calls[1].1.as_deref().expect("batch item IDs on stdin");
let batch_input: Vec<serde_json::Value> = serde_json::from_str(batch_input).unwrap();
assert_eq!(batch_input.len(), 10);
for index in 0..10 {
let item_id = format!("item-{index}");
assert!(batch_input.iter().any(|entry| entry["id"] == item_id));
assert!(secret_matches(
&results,
&format!("SECRET_{index}"),
&format!("value-{index}")
));
}
}
#[test]
fn empty_batch_does_not_invoke_op() {
use std::sync::Arc;
let mut provider = OnePasswordProvider::new(config("onepassword://Personal"));
provider.command_override = Some(Arc::new(|_, _| {
panic!("empty batch must not invoke the command seam")
}));
assert!(provider.get_many(&[]).unwrap().is_empty());
}
}