use std::process::{Command, Stdio};
use crate::config::{VaultConfig, VaultProvider};
use crate::{Error, Result};
pub trait Vault {
fn precheck(&self) -> Result<()>;
fn fetch(&self, item: &str) -> Result<Vec<u8>>;
fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()>;
fn provider_name(&self) -> &'static str;
}
pub fn driver(cfg: &VaultConfig) -> Box<dyn Vault> {
match cfg.provider {
VaultProvider::Bitwarden => Box::new(BitwardenVault),
VaultProvider::OnePassword => Box::new(OnePasswordVault),
}
}
struct BitwardenVault;
impl Vault for BitwardenVault {
fn provider_name(&self) -> &'static str {
"Bitwarden"
}
fn precheck(&self) -> Result<()> {
let out = Command::new("bw").args(["status"]).output().map_err(|e| {
Error::Other(anyhow::anyhow!(
"invoking `bw status`: {e} — is the Bitwarden CLI installed?"
))
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Other(anyhow::anyhow!(
"bw status failed: {}",
stderr.trim()
)));
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|e| Error::Other(anyhow::anyhow!("parse bw status output: {e}")))?;
match v.get("status").and_then(|s| s.as_str()) {
Some("unlocked") => Ok(()),
Some("locked") => Err(Error::Other(anyhow::anyhow!(
"Bitwarden vault is locked. Run `bw unlock` and follow \
its instructions to export the BW_SESSION env var, then \
retry. (BW vault unlock can use a passkey via the web \
vault flow if you've set that up.)"
))),
Some("unauthenticated") => Err(Error::Other(anyhow::anyhow!(
"Bitwarden CLI is not logged in. Run `bw login` (or \
`bw login --apikey` for non-interactive SSO/API-key \
use), then `bw unlock`, then retry."
))),
other => Err(Error::Other(anyhow::anyhow!(
"unexpected `bw status` output: status={other:?}"
))),
}
}
fn fetch(&self, item: &str) -> Result<Vec<u8>> {
let output = Command::new("bw")
.args(["get", "notes", item])
.output()
.map_err(|e| Error::Other(anyhow::anyhow!(
"invoking `bw`: {e} — install Bitwarden CLI and run `bw login` + `bw unlock` once"
)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(anyhow::anyhow!(
"bw get notes {item:?} failed: {}",
stderr.trim()
)));
}
Ok(output.stdout)
}
fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
let content_str = std::str::from_utf8(content)
.map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
let existing = Command::new("bw")
.args(["get", "item", item])
.output()
.map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
let item_json = serde_json::json!({
"type": 2, "name": item,
"notes": content_str,
"secureNote": { "type": 0 }, });
let payload = serde_json::to_vec(&item_json)
.map_err(|e| Error::Other(anyhow::anyhow!("serialise bw item JSON: {e}")))?;
let encoded = bw_encode(&payload)?;
if existing.status.success() {
if !force {
return Err(Error::Other(anyhow::anyhow!(
"Bitwarden item {item:?} already exists; pass --force to overwrite"
)));
}
let existing_value: serde_json::Value = serde_json::from_slice(&existing.stdout)
.map_err(|e| Error::Other(anyhow::anyhow!("parse bw get item output: {e}")))?;
let id = existing_value
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Other(anyhow::anyhow!("bw item {item:?} has no id field")))?;
run_bw_with_stdin(&["edit", "item", id], encoded.as_bytes())?;
} else {
run_bw_with_stdin(&["create", "item"], encoded.as_bytes())?;
}
Ok(())
}
}
fn bw_encode(payload: &[u8]) -> Result<String> {
use base64::Engine as _;
Ok(base64::engine::general_purpose::STANDARD.encode(payload))
}
fn run_bw_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
let mut child = Command::new("bw")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
child
.stdin
.as_mut()
.ok_or_else(|| Error::Other(anyhow::anyhow!("bw stdin closed early")))?
.write_all(stdin_bytes)
.map_err(|e| Error::Other(anyhow::anyhow!("writing to bw stdin: {e}")))?;
let output = child
.wait_with_output()
.map_err(|e| Error::Other(anyhow::anyhow!("waiting on bw: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(anyhow::anyhow!(
"bw {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(())
}
struct OnePasswordVault;
impl Vault for OnePasswordVault {
fn provider_name(&self) -> &'static str {
"1Password"
}
fn precheck(&self) -> Result<()> {
let out = Command::new("op").args(["whoami"]).output().map_err(|e| {
Error::Other(anyhow::anyhow!(
"invoking `op whoami`: {e} — is the 1Password CLI installed?"
))
})?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
Err(Error::Other(anyhow::anyhow!(
"1Password CLI is not signed in: {}. \
Run `op signin` (or unlock the 1Password desktop app to \
auto-share its session via the CLI integration), then retry.",
stderr.trim()
)))
}
fn fetch(&self, item: &str) -> Result<Vec<u8>> {
let output = Command::new("op")
.args(["item", "get", item, "--field", "notesPlain"])
.output()
.map_err(|e| {
Error::Other(anyhow::anyhow!(
"invoking `op`: {e} — install 1Password CLI and run `op signin` once"
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(anyhow::anyhow!(
"op item get {item:?} --field notesPlain failed: {}",
stderr.trim()
)));
}
Ok(output.stdout)
}
fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
let content_str = std::str::from_utf8(content)
.map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
let existing = Command::new("op")
.args(["item", "get", item])
.output()
.map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
let template = serde_json::json!({
"title": item,
"category": "SECURE_NOTE",
"fields": [
{
"id": "notesPlain",
"type": "STRING",
"purpose": "NOTES",
"label": "notesPlain",
"value": content_str,
}
],
});
let payload = serde_json::to_vec(&template)
.map_err(|e| Error::Other(anyhow::anyhow!("serialise op item template: {e}")))?;
if existing.status.success() {
if !force {
return Err(Error::Other(anyhow::anyhow!(
"1Password item {item:?} already exists; pass --force to overwrite"
)));
}
run_op_with_stdin(&["item", "edit", item, "-"], &payload)?;
} else {
run_op_with_stdin(&["item", "create", "-"], &payload)?;
}
Ok(())
}
}
fn run_op_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
let mut child = Command::new("op")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
child
.stdin
.as_mut()
.ok_or_else(|| Error::Other(anyhow::anyhow!("op stdin closed early")))?
.write_all(stdin_bytes)
.map_err(|e| Error::Other(anyhow::anyhow!("writing to op stdin: {e}")))?;
let output = child
.wait_with_output()
.map_err(|e| Error::Other(anyhow::anyhow!("waiting on op: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Other(anyhow::anyhow!(
"op {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(())
}