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 assert_signed_in(&self) -> Result<()>;
fn find_vault(&self, name: &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 assert_signed_in(&self) -> Result<()> {
assert_signed_in()
}
fn find_vault(&self, name: &str) -> Result<Option<String>> {
find_vault(name)
}
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 assert_signed_in() -> Result<()> {
match run_op(&["whoami"]) {
Ok(_) => Ok(()),
Err(e) => {
if let Some(f) = e.downcast_ref::<OpFailure>() {
if f.stderr.to_lowercase().contains("not signed in") {
bail!("You are not signed in to 1Password. Run `op signin` first.");
}
}
Err(e)
}
}
}
pub fn find_vault(name: &str) -> Result<Option<String>> {
match run_op(&["vault", "get", name, "--format=json"]) {
Ok(stdout) => Ok(Some(parse_id(&stdout)?)),
Err(e) => {
if e.downcast_ref::<OpFailure>().is_some() {
Ok(None)
} else {
Err(e)
}
}
}
}
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());
}
}