Skip to main content

dotr_dear/prompt_store/
mod.rs

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    // Unlike Keychain/Bitwarden, the file already holds everything - no
73    // need to scope the read to just the currently-declared prompt keys.
74    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    // No real "spec" for entry naming here — `DOTR`/`DOTR_` prefixes plus
106    // the repo path just keep entries identifiable and scoped per-repo.
107    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
143// Bitwarden backend — one secure note stores every bitwarden-backed
144// variable for the repo, as a TOML blob in the note's "Notes" field.
145
146struct BitwardenState {
147    id: String,
148    envelope: serde_json::Value,
149    /// Same `key = "value"` TOML format as `.uservariables.toml`, so a
150    /// note's content is copy-pasteable to/from the file backend.
151    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    /// Creates the note fresh as an empty Secure Note, matching the exact
178    /// item shape `bw create item` expects (verified against
179    /// bitwarden/clients' CipherExport/SecureNoteExport templates).
180    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    /// Runs a `bw` subcommand non-interactively, with `--session` appended
210    /// if we already have one cached from `authenticate_interactively`.
211    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    /// Pulls the latest vault data from the server before we read/write,
223    /// so a note edited elsewhere (another device, the web vault) isn't
224    /// missed by a stale local cache. Best-effort and silent: called
225    /// speculatively before we necessarily know we're authenticated, so a
226    /// failure here isn't itself noteworthy - a real problem still
227    /// surfaces properly from the get/set call that follows it.
228    fn sync(&self) {
229        let _ = self.bw(&["sync"]);
230    }
231
232    /// Drives `bw login`/`bw unlock` interactively and caches the
233    /// resulting session for subsequent `bw` calls.
234    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        // Prompt text goes to stderr (inherited, so it's visible); the raw
263        // session key on success is the only thing on stdout (captured).
264        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        // Fresh session — pull the latest vault data before reading/writing,
280        // so a note edited elsewhere isn't missed by a stale local cache.
281        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        // Covers the "already had a valid session" case (e.g. an exported
291        // BW_SESSION) - if we end up authenticating below instead, that
292        // path syncs again right after, once we know it's worth reporting
293        // a failure.
294        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            // Only auto-create on a genuine "doesn't exist yet" - anything
305            // else (ambiguous name match, permissions, ...) must surface as
306            // an error instead, or a lookup failure for any other reason
307            // would silently spawn a brand-new empty note, shadowing
308            // whatever real note/values you already had.
309            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        // Only lock back up if we're the ones who unlocked it — a
404        // pre-existing BW_SESSION from the user's own shell is left alone.
405        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}