1use std::process::{Command, Stdio};
28
29use crate::config::{VaultConfig, VaultProvider};
30use crate::{Error, Result};
31
32pub trait Vault {
36 fn precheck(&self) -> Result<()>;
45
46 fn fetch(&self, item: &str) -> Result<Vec<u8>>;
50
51 fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()>;
55
56 fn provider_name(&self) -> &'static str;
58}
59
60pub fn driver(cfg: &VaultConfig) -> Box<dyn Vault> {
62 match cfg.provider {
63 VaultProvider::Bitwarden => Box::new(BitwardenVault),
64 VaultProvider::OnePassword => Box::new(OnePasswordVault),
65 }
66}
67
68struct BitwardenVault;
71
72impl Vault for BitwardenVault {
73 fn provider_name(&self) -> &'static str {
74 "Bitwarden"
75 }
76
77 fn precheck(&self) -> Result<()> {
78 let out = Command::new("bw").args(["status"]).output().map_err(|e| {
79 Error::Other(anyhow::anyhow!(
80 "invoking `bw status`: {e} — is the Bitwarden CLI installed?"
81 ))
82 })?;
83 if !out.status.success() {
84 let stderr = String::from_utf8_lossy(&out.stderr);
85 return Err(Error::Other(anyhow::anyhow!(
86 "bw status failed: {}",
87 stderr.trim()
88 )));
89 }
90 let v: serde_json::Value = serde_json::from_slice(&out.stdout)
91 .map_err(|e| Error::Other(anyhow::anyhow!("parse bw status output: {e}")))?;
92 match v.get("status").and_then(|s| s.as_str()) {
93 Some("unlocked") => Ok(()),
94 Some("locked") => Err(Error::Other(anyhow::anyhow!(
95 "Bitwarden vault is locked. Run `bw unlock` and follow \
96 its instructions to export the BW_SESSION env var, then \
97 retry. (BW vault unlock can use a passkey via the web \
98 vault flow if you've set that up.)"
99 ))),
100 Some("unauthenticated") => Err(Error::Other(anyhow::anyhow!(
101 "Bitwarden CLI is not logged in. Run `bw login` (or \
102 `bw login --apikey` for non-interactive SSO/API-key \
103 use), then `bw unlock`, then retry."
104 ))),
105 other => Err(Error::Other(anyhow::anyhow!(
106 "unexpected `bw status` output: status={other:?}"
107 ))),
108 }
109 }
110
111 fn fetch(&self, item: &str) -> Result<Vec<u8>> {
112 let output = Command::new("bw")
117 .args(["get", "notes", item])
118 .output()
119 .map_err(|e| Error::Other(anyhow::anyhow!(
120 "invoking `bw`: {e} — install Bitwarden CLI and run `bw login` + `bw unlock` once"
121 )))?;
122 if !output.status.success() {
123 let stderr = String::from_utf8_lossy(&output.stderr);
124 return Err(Error::Other(anyhow::anyhow!(
125 "bw get notes {item:?} failed: {}",
126 stderr.trim()
127 )));
128 }
129 Ok(output.stdout)
130 }
131
132 fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
133 let content_str = std::str::from_utf8(content)
134 .map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
135
136 let existing = Command::new("bw")
139 .args(["get", "item", item])
140 .output()
141 .map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
142
143 let item_json = serde_json::json!({
144 "type": 2, "name": item,
146 "notes": content_str,
147 "secureNote": { "type": 0 }, });
149 let payload = serde_json::to_vec(&item_json)
150 .map_err(|e| Error::Other(anyhow::anyhow!("serialise bw item JSON: {e}")))?;
151 let encoded = bw_encode(&payload)?;
152
153 if existing.status.success() {
154 if !force {
155 return Err(Error::Other(anyhow::anyhow!(
156 "Bitwarden item {item:?} already exists; pass --force to overwrite"
157 )));
158 }
159 let existing_value: serde_json::Value = serde_json::from_slice(&existing.stdout)
161 .map_err(|e| Error::Other(anyhow::anyhow!("parse bw get item output: {e}")))?;
162 let id = existing_value
163 .get("id")
164 .and_then(|v| v.as_str())
165 .ok_or_else(|| Error::Other(anyhow::anyhow!("bw item {item:?} has no id field")))?;
166 run_bw_with_stdin(&["edit", "item", id], encoded.as_bytes())?;
167 } else {
168 run_bw_with_stdin(&["create", "item"], encoded.as_bytes())?;
169 }
170 Ok(())
171 }
172}
173
174fn bw_encode(payload: &[u8]) -> Result<String> {
178 use base64::Engine as _;
179 Ok(base64::engine::general_purpose::STANDARD.encode(payload))
180}
181
182fn run_bw_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
183 use std::io::Write as _;
184 let mut child = Command::new("bw")
185 .args(args)
186 .stdin(Stdio::piped())
187 .stdout(Stdio::piped())
188 .stderr(Stdio::piped())
189 .spawn()
190 .map_err(|e| Error::Other(anyhow::anyhow!("invoking `bw`: {e}")))?;
191 child
192 .stdin
193 .as_mut()
194 .ok_or_else(|| Error::Other(anyhow::anyhow!("bw stdin closed early")))?
195 .write_all(stdin_bytes)
196 .map_err(|e| Error::Other(anyhow::anyhow!("writing to bw stdin: {e}")))?;
197 let output = child
198 .wait_with_output()
199 .map_err(|e| Error::Other(anyhow::anyhow!("waiting on bw: {e}")))?;
200 if !output.status.success() {
201 let stderr = String::from_utf8_lossy(&output.stderr);
202 return Err(Error::Other(anyhow::anyhow!(
203 "bw {} failed: {}",
204 args.join(" "),
205 stderr.trim()
206 )));
207 }
208 Ok(())
209}
210
211struct OnePasswordVault;
214
215impl Vault for OnePasswordVault {
216 fn provider_name(&self) -> &'static str {
217 "1Password"
218 }
219
220 fn precheck(&self) -> Result<()> {
221 let out = Command::new("op").args(["whoami"]).output().map_err(|e| {
228 Error::Other(anyhow::anyhow!(
229 "invoking `op whoami`: {e} — is the 1Password CLI installed?"
230 ))
231 })?;
232 if out.status.success() {
233 return Ok(());
234 }
235 let stderr = String::from_utf8_lossy(&out.stderr);
236 Err(Error::Other(anyhow::anyhow!(
237 "1Password CLI is not signed in: {}. \
238 Run `op signin` (or unlock the 1Password desktop app to \
239 auto-share its session via the CLI integration), then retry.",
240 stderr.trim()
241 )))
242 }
243
244 fn fetch(&self, item: &str) -> Result<Vec<u8>> {
245 let output = Command::new("op")
248 .args(["item", "get", item, "--field", "notesPlain"])
249 .output()
250 .map_err(|e| {
251 Error::Other(anyhow::anyhow!(
252 "invoking `op`: {e} — install 1Password CLI and run `op signin` once"
253 ))
254 })?;
255 if !output.status.success() {
256 let stderr = String::from_utf8_lossy(&output.stderr);
257 return Err(Error::Other(anyhow::anyhow!(
258 "op item get {item:?} --field notesPlain failed: {}",
259 stderr.trim()
260 )));
261 }
262 Ok(output.stdout)
263 }
264
265 fn store(&self, item: &str, content: &[u8], force: bool) -> Result<()> {
266 let content_str = std::str::from_utf8(content)
267 .map_err(|e| Error::Other(anyhow::anyhow!("vault content is not valid UTF-8: {e}")))?;
268
269 let existing = Command::new("op")
270 .args(["item", "get", item])
271 .output()
272 .map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
273
274 let template = serde_json::json!({
281 "title": item,
282 "category": "SECURE_NOTE",
283 "fields": [
284 {
285 "id": "notesPlain",
286 "type": "STRING",
287 "purpose": "NOTES",
288 "label": "notesPlain",
289 "value": content_str,
290 }
291 ],
292 });
293 let payload = serde_json::to_vec(&template)
294 .map_err(|e| Error::Other(anyhow::anyhow!("serialise op item template: {e}")))?;
295
296 if existing.status.success() {
297 if !force {
298 return Err(Error::Other(anyhow::anyhow!(
299 "1Password item {item:?} already exists; pass --force to overwrite"
300 )));
301 }
302 run_op_with_stdin(&["item", "edit", item, "-"], &payload)?;
303 } else {
304 run_op_with_stdin(&["item", "create", "-"], &payload)?;
305 }
306 Ok(())
307 }
308}
309
310fn run_op_with_stdin(args: &[&str], stdin_bytes: &[u8]) -> Result<()> {
311 use std::io::Write as _;
312 let mut child = Command::new("op")
313 .args(args)
314 .stdin(Stdio::piped())
315 .stdout(Stdio::piped())
316 .stderr(Stdio::piped())
317 .spawn()
318 .map_err(|e| Error::Other(anyhow::anyhow!("invoking `op`: {e}")))?;
319 child
320 .stdin
321 .as_mut()
322 .ok_or_else(|| Error::Other(anyhow::anyhow!("op stdin closed early")))?
323 .write_all(stdin_bytes)
324 .map_err(|e| Error::Other(anyhow::anyhow!("writing to op stdin: {e}")))?;
325 let output = child
326 .wait_with_output()
327 .map_err(|e| Error::Other(anyhow::anyhow!("waiting on op: {e}")))?;
328 if !output.status.success() {
329 let stderr = String::from_utf8_lossy(&output.stderr);
330 return Err(Error::Other(anyhow::anyhow!(
331 "op {} failed: {}",
332 args.join(" "),
333 stderr.trim()
334 )));
335 }
336 Ok(())
337}