use std::io::Write;
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use tempfile::TempDir;
fn run_op(args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new("op").args(args).output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"The 1Password CLI (`op`) was not found on your PATH.\n\
Install it: https://developer.1password.com/docs/cli/get-started/"
)
} else {
anyhow!("failed to run op: {e}")
}
})?;
if output.status.success() {
Ok(output.stdout)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(OpFailure {
stderr: stderr.trim().to_string(),
}
.into())
}
}
#[derive(Debug)]
pub struct OpFailure {
pub stderr: String,
}
impl std::fmt::Display for OpFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.stderr)
}
}
impl std::error::Error for OpFailure {}
#[derive(Deserialize)]
struct IdEnvelope {
#[serde(default)]
id: Option<String>,
#[serde(default)]
uuid: Option<String>,
}
fn parse_id(stdout: &[u8]) -> Result<String> {
let env: IdEnvelope = serde_json::from_slice(stdout).with_context(|| {
format!(
"could not parse op JSON response: {}",
String::from_utf8_lossy(stdout).trim()
)
})?;
env.uuid.or(env.id).ok_or_else(|| {
anyhow!(
"could not find an id/uuid in op's response: {}",
String::from_utf8_lossy(stdout).trim()
)
})
}
pub trait OpBackend {
fn is_signed_in(&self) -> Result<bool>;
fn sign_in(&self) -> Result<()>;
fn find_vault(&self, name: &str) -> Result<Option<String>>;
fn vault_name(&self, id: &str) -> Result<Option<String>>;
fn create_vault(&self, name: &str, description: &str) -> Result<String>;
fn create_document(
&self,
vault: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<String>;
fn edit_document(
&self,
vault: &str,
id: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<()>;
fn get_document(&self, vault: &str, id: &str) -> Result<Vec<u8>>;
}
pub struct RealOp;
impl OpBackend for RealOp {
fn is_signed_in(&self) -> Result<bool> {
is_signed_in()
}
fn sign_in(&self) -> Result<()> {
sign_in()
}
fn find_vault(&self, name: &str) -> Result<Option<String>> {
find_vault(name)
}
fn vault_name(&self, id: &str) -> Result<Option<String>> {
vault_name(id)
}
fn create_vault(&self, name: &str, description: &str) -> Result<String> {
create_vault(name, description)
}
fn create_document(
&self,
vault: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<String> {
create_document(vault, title, file_name, content)
}
fn edit_document(
&self,
vault: &str,
id: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<()> {
edit_document(vault, id, title, file_name, content)
}
fn get_document(&self, vault: &str, id: &str) -> Result<Vec<u8>> {
get_document(vault, id)
}
}
pub fn is_signed_in() -> Result<bool> {
match run_op(&["whoami"]) {
Ok(_) => Ok(true),
Err(e) => {
if let Some(f) = e.downcast_ref::<OpFailure>() {
if f.stderr.to_lowercase().contains("not signed in") {
return Ok(false);
}
}
Err(e)
}
}
}
pub fn sign_in() -> Result<()> {
let status = Command::new("op").arg("signin").status().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"The 1Password CLI (`op`) was not found on your PATH.\n\
Install it: https://developer.1password.com/docs/cli/get-started/"
)
} else {
anyhow!("failed to run op signin: {e}")
}
})?;
if status.success() {
Ok(())
} else {
bail!("`op signin` did not complete successfully. Not signed in.");
}
}
fn vault_not_found(stderr: &str) -> bool {
let s = stderr.to_lowercase().replace('\u{2019}', "'");
s.contains("isn't a vault") || s.contains("is not a vault") || s.contains("vault not found")
}
fn vault_get_json(query: &str) -> Result<Option<Vec<u8>>> {
match run_op(&["vault", "get", query, "--format=json"]) {
Ok(stdout) => Ok(Some(stdout)),
Err(e) => match e.downcast_ref::<OpFailure>() {
Some(f) if vault_not_found(&f.stderr) => Ok(None),
_ => Err(e),
},
}
}
pub fn find_vault(name: &str) -> Result<Option<String>> {
vault_get_json(name)?
.map(|stdout| parse_id(&stdout))
.transpose()
}
#[derive(Deserialize)]
struct VaultEnvelope {
name: String,
}
pub fn vault_name(id: &str) -> Result<Option<String>> {
vault_get_json(id)?
.map(|stdout| {
let env: VaultEnvelope = serde_json::from_slice(&stdout).with_context(|| {
format!(
"could not parse op JSON response: {}",
String::from_utf8_lossy(&stdout).trim()
)
})?;
Ok(env.name)
})
.transpose()
}
pub fn create_vault(name: &str, description: &str) -> Result<String> {
let stdout = run_op(&[
"vault",
"create",
name,
"--description",
description,
"--format=json",
])?;
parse_id(&stdout)
}
fn temp_file_with(file_name: &str, content: &[u8]) -> Result<(TempDir, std::path::PathBuf)> {
let dir = tempfile::Builder::new()
.prefix("dotprot-")
.tempdir()
.context("creating temp dir for op document")?;
let path = dir.path().join(file_name);
let mut f = open_owner_only(&path)?;
f.write_all(content)
.context("writing secret to temp file")?;
f.flush().ok();
Ok((dir, path))
}
fn open_owner_only(path: &std::path::Path) -> Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path).context("opening temp file")
}
pub fn create_document(
vault: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<String> {
let (_dir, path) = temp_file_with(file_name, content)?;
let path_str = path.to_string_lossy();
let stdout = run_op(&[
"document",
"create",
&path_str,
"--vault",
vault,
"--title",
title,
"--file-name",
file_name,
"--format=json",
])?;
parse_id(&stdout)
}
pub fn edit_document(
vault: &str,
id: &str,
title: &str,
file_name: &str,
content: &[u8],
) -> Result<()> {
let (_dir, path) = temp_file_with(file_name, content)?;
let path_str = path.to_string_lossy();
run_op(&[
"document",
"edit",
id,
&path_str,
"--vault",
vault,
"--title",
title,
"--file-name",
file_name,
])?;
Ok(())
}
pub fn get_document(vault: &str, id: &str) -> Result<Vec<u8>> {
run_op(&["document", "get", id, "--vault", vault, "--force"])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_id_prefers_uuid() {
let out = br#"{"uuid":"U123","id":"I456"}"#;
assert_eq!(parse_id(out).unwrap(), "U123");
}
#[test]
fn parse_id_falls_back_to_id() {
let out = br#"{"id":"I456"}"#;
assert_eq!(parse_id(out).unwrap(), "I456");
}
#[test]
fn parse_id_errors_when_neither_present() {
let out = br#"{"createdAt":"now"}"#;
assert!(parse_id(out).is_err());
}
#[test]
fn vault_not_found_matches_real_op_message() {
assert!(vault_not_found(
r#"[ERROR] 2026/07/04 10:49:55 "zzz" isn't a vault in this account. Specify the vault with its ID or name."#
));
}
#[test]
fn vault_not_found_tolerates_typographic_apostrophe() {
assert!(vault_not_found(
"[ERROR] \"zzz\" isn\u{2019}t a vault in this account."
));
}
#[test]
fn vault_not_found_rejects_other_failures() {
assert!(!vault_not_found("network error: dial tcp: i/o timeout"));
assert!(!vault_not_found("you are not currently signed in"));
assert!(!vault_not_found(r#"More than one vault matches ".prot""#));
}
}