1use std::{
2 fs,
3 io::Write,
4 path::{Path, PathBuf},
5 process::{Command, Stdio},
6};
7
8use serde::{Deserialize, Serialize};
9use toml::Table;
10
11use crate::utils::{LogLevel, cprintln};
12
13#[cfg(test)]
14mod tests;
15
16pub const DEFAULT_BITWARDEN_NOTE: &str = "dotr-secrets";
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum PromptBackendType {
21 #[default]
22 File,
23 Keychain,
24 Bitwarden,
25}
26
27impl PromptBackendType {
28 pub fn as_str(&self) -> &'static str {
29 match self {
30 PromptBackendType::File => "file",
31 PromptBackendType::Keychain => "keychain",
32 PromptBackendType::Bitwarden => "bitwarden",
33 }
34 }
35
36 pub fn parse(s: &str) -> anyhow::Result<Self> {
37 match s {
38 "file" => Ok(PromptBackendType::File),
39 "keychain" => Ok(PromptBackendType::Keychain),
40 "bitwarden" => Ok(PromptBackendType::Bitwarden),
41 other => {
42 anyhow::bail!("unknown backend '{other}' (expected file, keychain, or bitwarden)")
43 }
44 }
45 }
46}
47
48pub trait PromptStoreBackend {
49 fn get(&mut self, cwd: &Path, keys: &[String]) -> anyhow::Result<Table>;
50 fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()>;
51}
52
53pub struct FileStore {}
54
55impl Default for FileStore {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl FileStore {
62 pub fn new() -> Self {
63 FileStore {}
64 }
65
66 fn get_file_path(&self, cwd: &Path) -> PathBuf {
67 cwd.join(".uservariables.toml")
68 }
69}
70
71impl PromptStoreBackend for FileStore {
72 fn get(&mut self, cwd: &Path, _keys: &[String]) -> anyhow::Result<Table> {
75 let path = self.get_file_path(cwd);
76 if !path.exists() {
77 return Ok(Table::new());
78 }
79 let content = fs::read_to_string(&path)?;
80 let records: Table = toml::from_str(&content)?;
81 Ok(records)
82 }
83
84 fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()> {
85 let path = self.get_file_path(cwd);
86 let content_string = toml::to_string(records)?;
87 fs::write(path, content_string)?;
88 Ok(())
89 }
90}
91
92pub struct KeychainStore {}
93
94impl Default for KeychainStore {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100impl KeychainStore {
101 pub fn new() -> Self {
102 KeychainStore {}
103 }
104
105 fn entry(&self, cwd: &Path, key: &str) -> anyhow::Result<keyring::Entry> {
108 let service = format!("DOTR:{}", cwd.display());
109 keyring::Entry::new(&service, &format!("DOTR_{key}"))
110 .map_err(|e| anyhow::anyhow!("Failed to access the OS keychain for '{key}': {e}"))
111 }
112}
113
114impl PromptStoreBackend for KeychainStore {
115 fn get(&mut self, cwd: &Path, keys: &[String]) -> anyhow::Result<Table> {
116 let mut records = Table::new();
117 for key in keys {
118 match self.entry(cwd, key)?.get_password() {
119 Ok(value) => {
120 records.insert(key.clone(), toml::Value::String(value));
121 }
122 Err(keyring::Error::NoEntry) => {}
123 Err(e) => {
124 anyhow::bail!("Failed to read '{key}' from the OS keychain: {e}");
125 }
126 }
127 }
128 Ok(records)
129 }
130
131 fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()> {
132 for (key, value) in records.iter() {
133 if let Some(v) = value.as_str() {
134 self.entry(cwd, key)?.set_password(v).map_err(|e| {
135 anyhow::anyhow!("Failed to save '{key}' to the OS keychain: {e}")
136 })?;
137 }
138 }
139 Ok(())
140 }
141}
142
143struct BitwardenState {
147 id: String,
148 envelope: serde_json::Value,
149 values: Table,
152}
153
154pub struct BitwardenStore {
155 note: String,
156 state: Option<BitwardenState>,
157 session: Option<String>,
158}
159
160impl BitwardenStore {
161 pub fn new(note: String) -> Self {
162 Self {
163 note,
164 state: None,
165 session: None,
166 }
167 }
168
169 fn auth_failed_hint(&self, detail: &str) -> anyhow::Error {
170 anyhow::anyhow!(
171 "Bitwarden authentication failed: {}\nMake sure `bw login`/`bw unlock` can \
172complete in this terminal.",
173 detail
174 )
175 }
176
177 fn create_note(&self) -> anyhow::Result<std::process::Output> {
181 cprintln(
182 &format!("Bitwarden note '{}' not found — creating it...", self.note),
183 &LogLevel::Info,
184 );
185 let template = serde_json::json!({
186 "organizationId": null,
187 "folderId": null,
188 "type": 2,
189 "name": self.note,
190 "notes": "",
191 "favorite": false,
192 "fields": [],
193 "reprompt": 0,
194 "secureNote": { "type": 0 },
195 "collectionIds": []
196 });
197 let encoded = run_bw_encode(&serde_json::to_string(&template)?)?;
198 let output = self.bw(&["create", "item", &encoded])?;
199 if !output.status.success() || output.stdout.is_empty() {
200 anyhow::bail!(
201 "Failed to create Bitwarden secure note '{}': {}",
202 self.note,
203 String::from_utf8_lossy(&output.stderr).trim()
204 );
205 }
206 Ok(output)
207 }
208
209 fn bw(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
212 let mut cmd = Command::new("bw");
213 cmd.args(args);
214 if let Some(session) = self.session.as_ref() {
215 cmd.args(["--session", session]);
216 }
217 cmd.stdin(Stdio::null())
218 .output()
219 .map_err(|e| anyhow::anyhow!("Failed to run `bw {}`: {e}", args.join(" ")))
220 }
221
222 fn sync(&self) {
229 let _ = self.bw(&["sync"]);
230 }
231
232 fn authenticate_interactively(&mut self) -> anyhow::Result<()> {
235 let status_output = Command::new("bw")
236 .arg("status")
237 .stdin(Stdio::null())
238 .output()
239 .map_err(|e| anyhow::anyhow!("Failed to run `bw status`: {e}"))?;
240 let status: serde_json::Value =
241 serde_json::from_slice(&status_output.stdout).unwrap_or(serde_json::Value::Null);
242
243 if status.get("status").and_then(|s| s.as_str()) == Some("unauthenticated") {
244 cprintln(
245 "Not logged in to Bitwarden — running `bw login`...",
246 &LogLevel::Info,
247 );
248 let login_ok = Command::new("bw")
249 .arg("login")
250 .stdin(Stdio::inherit())
251 .stdout(Stdio::inherit())
252 .stderr(Stdio::inherit())
253 .status()
254 .map_err(|e| anyhow::anyhow!("Failed to run `bw login`: {e}"))?
255 .success();
256 if !login_ok {
257 anyhow::bail!("`bw login` did not complete successfully");
258 }
259 }
260
261 cprintln("Unlocking your Bitwarden vault...", &LogLevel::Info);
262 let unlock_output = Command::new("bw")
265 .args(["unlock", "--raw"])
266 .stdin(Stdio::inherit())
267 .stdout(Stdio::piped())
268 .stderr(Stdio::inherit())
269 .output()
270 .map_err(|e| anyhow::anyhow!("Failed to run `bw unlock`: {e}"))?;
271 if !unlock_output.status.success() || unlock_output.stdout.is_empty() {
272 anyhow::bail!("`bw unlock` did not complete successfully");
273 }
274 self.session = Some(
275 String::from_utf8_lossy(&unlock_output.stdout)
276 .trim()
277 .to_string(),
278 );
279 self.sync();
282 Ok(())
283 }
284
285 fn ensure_loaded(&mut self) -> anyhow::Result<()> {
286 if self.state.is_some() {
287 return Ok(());
288 }
289
290 self.sync();
295
296 let mut output = self.bw(&["get", "item", &self.note])?;
297 if !output.status.success() || output.stdout.is_empty() {
298 self.authenticate_interactively()
299 .map_err(|e| self.auth_failed_hint(&e.to_string()))?;
300 output = self.bw(&["get", "item", &self.note])?;
301 }
302 if !output.status.success() || output.stdout.is_empty() {
303 let stderr = String::from_utf8_lossy(&output.stderr);
304 if stderr.trim() == "Not found." {
310 output = self.create_note()?;
311 } else {
312 anyhow::bail!(
313 "`bw get item {}` failed: {}\nThis isn't a \"note doesn't exist yet\" \
314case, so dotr won't create a new note automatically - fix the underlying issue (e.g. \
315if multiple items are named '{}', rename or delete the extras so the name is unique) \
316and try again.",
317 self.note,
318 stderr.trim(),
319 self.note
320 );
321 }
322 }
323
324 let stdout = String::from_utf8_lossy(&output.stdout);
325 let envelope: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| {
326 anyhow::anyhow!(
327 "Failed to parse `bw get item {}` output as JSON: {e}",
328 self.note
329 )
330 })?;
331 let id = envelope
332 .get("id")
333 .and_then(|v| v.as_str())
334 .ok_or_else(|| anyhow::anyhow!("Bitwarden item '{}' has no id", self.note))?
335 .to_string();
336 let notes = envelope.get("notes").and_then(|v| v.as_str()).unwrap_or("");
337 let values: Table = if notes.trim().is_empty() {
338 Table::new()
339 } else {
340 toml::from_str(notes).map_err(|e| {
341 anyhow::anyhow!(
342 "Bitwarden note '{}' doesn't contain valid `key = \"value\"` TOML content \
343(the same format as .uservariables.toml): {e}",
344 self.note
345 )
346 })?
347 };
348
349 self.state = Some(BitwardenState {
350 id,
351 envelope,
352 values,
353 });
354 Ok(())
355 }
356}
357
358impl PromptStoreBackend for BitwardenStore {
359 fn get(&mut self, _cwd: &Path, keys: &[String]) -> anyhow::Result<Table> {
360 self.ensure_loaded()?;
361 let values = &self.state.as_ref().expect("just ensured loaded").values;
362 let mut records = Table::new();
363 for key in keys {
364 if let Some(v) = values.get(key) {
365 records.insert(key.clone(), v.clone());
366 }
367 }
368 Ok(records)
369 }
370
371 fn save(&mut self, _cwd: &Path, records: &Table) -> anyhow::Result<()> {
372 self.ensure_loaded()?;
373
374 let (id, encoded_envelope) = {
375 let state = self.state.as_mut().expect("just ensured loaded");
376 for (key, value) in records.iter() {
377 state.values.insert(key.clone(), value.clone());
378 }
379 let notes_toml = toml::to_string(&state.values)?;
380 state.envelope["notes"] = serde_json::Value::String(notes_toml);
381 (state.id.clone(), serde_json::to_string(&state.envelope)?)
382 };
383
384 let encoded = run_bw_encode(&encoded_envelope)?;
385
386 let mut edit_output = self.bw(&["edit", "item", &id, &encoded])?;
387 if !edit_output.status.success() {
388 self.authenticate_interactively()?;
389 edit_output = self.bw(&["edit", "item", &id, &encoded])?;
390 if !edit_output.status.success() {
391 anyhow::bail!(
392 "`bw edit item` failed: {}",
393 String::from_utf8_lossy(&edit_output.stderr).trim()
394 );
395 }
396 }
397 Ok(())
398 }
399}
400
401impl Drop for BitwardenStore {
402 fn drop(&mut self) {
403 if self.session.is_some() {
406 cprintln("Locking your Bitwarden vault...", &LogLevel::Info);
407 match self.bw(&["lock"]) {
408 Ok(output) if !output.status.success() => cprintln(
409 &format!(
410 "Failed to re-lock the Bitwarden vault: {}",
411 String::from_utf8_lossy(&output.stderr).trim()
412 ),
413 &LogLevel::Warning,
414 ),
415 Err(e) => cprintln(
416 &format!("Failed to re-lock the Bitwarden vault: {e}"),
417 &LogLevel::Warning,
418 ),
419 _ => {}
420 }
421 }
422 }
423}
424
425fn run_bw_encode(json: &str) -> anyhow::Result<String> {
426 let mut child = Command::new("bw")
427 .arg("encode")
428 .stdin(Stdio::piped())
429 .stdout(Stdio::piped())
430 .stderr(Stdio::piped())
431 .spawn()
432 .map_err(|e| anyhow::anyhow!("Failed to run `bw encode`: {e}"))?;
433 child
434 .stdin
435 .take()
436 .expect("stdin was piped")
437 .write_all(json.as_bytes())?;
438 let output = child
439 .wait_with_output()
440 .map_err(|e| anyhow::anyhow!("Failed to run `bw encode`: {e}"))?;
441 if !output.status.success() {
442 anyhow::bail!(
443 "`bw encode` failed: {}",
444 String::from_utf8_lossy(&output.stderr).trim()
445 );
446 }
447 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
448}