dotr-dear 2.0.1

A dotfiles manager as dear as a daughter.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use serde::{Deserialize, Serialize};
use toml::Table;

use crate::utils::{LogLevel, cprintln};

#[cfg(test)]
mod tests;

pub const DEFAULT_BITWARDEN_NOTE: &str = "dotr-secrets";

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptBackendType {
    #[default]
    File,
    Keychain,
    Bitwarden,
}

impl PromptBackendType {
    pub fn as_str(&self) -> &'static str {
        match self {
            PromptBackendType::File => "file",
            PromptBackendType::Keychain => "keychain",
            PromptBackendType::Bitwarden => "bitwarden",
        }
    }

    pub fn parse(s: &str) -> anyhow::Result<Self> {
        match s {
            "file" => Ok(PromptBackendType::File),
            "keychain" => Ok(PromptBackendType::Keychain),
            "bitwarden" => Ok(PromptBackendType::Bitwarden),
            other => {
                anyhow::bail!("unknown backend '{other}' (expected file, keychain, or bitwarden)")
            }
        }
    }
}

pub trait PromptStoreBackend {
    fn get(&mut self, cwd: &Path, keys: &[String]) -> anyhow::Result<Table>;
    fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()>;
}

pub struct FileStore {}

impl Default for FileStore {
    fn default() -> Self {
        Self::new()
    }
}

impl FileStore {
    pub fn new() -> Self {
        FileStore {}
    }

    fn get_file_path(&self, cwd: &Path) -> PathBuf {
        cwd.join(".uservariables.toml")
    }
}

impl PromptStoreBackend for FileStore {
    // Unlike Keychain/Bitwarden, the file already holds everything - no
    // need to scope the read to just the currently-declared prompt keys.
    fn get(&mut self, cwd: &Path, _keys: &[String]) -> anyhow::Result<Table> {
        let path = self.get_file_path(cwd);
        if !path.exists() {
            return Ok(Table::new());
        }
        let content = fs::read_to_string(&path)?;
        let records: Table = toml::from_str(&content)?;
        Ok(records)
    }

    fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()> {
        let path = self.get_file_path(cwd);
        let content_string = toml::to_string(records)?;
        fs::write(path, content_string)?;
        Ok(())
    }
}

pub struct KeychainStore {}

impl Default for KeychainStore {
    fn default() -> Self {
        Self::new()
    }
}

impl KeychainStore {
    pub fn new() -> Self {
        KeychainStore {}
    }

    // No real "spec" for entry naming here — `DOTR`/`DOTR_` prefixes plus
    // the repo path just keep entries identifiable and scoped per-repo.
    fn entry(&self, cwd: &Path, key: &str) -> anyhow::Result<keyring::Entry> {
        let service = format!("DOTR:{}", cwd.display());
        keyring::Entry::new(&service, &format!("DOTR_{key}"))
            .map_err(|e| anyhow::anyhow!("Failed to access the OS keychain for '{key}': {e}"))
    }
}

impl PromptStoreBackend for KeychainStore {
    fn get(&mut self, cwd: &Path, keys: &[String]) -> anyhow::Result<Table> {
        let mut records = Table::new();
        for key in keys {
            match self.entry(cwd, key)?.get_password() {
                Ok(value) => {
                    records.insert(key.clone(), toml::Value::String(value));
                }
                Err(keyring::Error::NoEntry) => {}
                Err(e) => {
                    anyhow::bail!("Failed to read '{key}' from the OS keychain: {e}");
                }
            }
        }
        Ok(records)
    }

    fn save(&mut self, cwd: &Path, records: &Table) -> anyhow::Result<()> {
        for (key, value) in records.iter() {
            if let Some(v) = value.as_str() {
                self.entry(cwd, key)?.set_password(v).map_err(|e| {
                    anyhow::anyhow!("Failed to save '{key}' to the OS keychain: {e}")
                })?;
            }
        }
        Ok(())
    }
}

// Bitwarden backend — one secure note stores every bitwarden-backed
// variable for the repo, as a TOML blob in the note's "Notes" field.

struct BitwardenState {
    id: String,
    envelope: serde_json::Value,
    /// Same `key = "value"` TOML format as `.uservariables.toml`, so a
    /// note's content is copy-pasteable to/from the file backend.
    values: Table,
}

pub struct BitwardenStore {
    note: String,
    state: Option<BitwardenState>,
    session: Option<String>,
}

impl BitwardenStore {
    pub fn new(note: String) -> Self {
        Self {
            note,
            state: None,
            session: None,
        }
    }

    fn auth_failed_hint(&self, detail: &str) -> anyhow::Error {
        anyhow::anyhow!(
            "Bitwarden authentication failed: {}\nMake sure `bw login`/`bw unlock` can \
complete in this terminal.",
            detail
        )
    }

    /// Creates the note fresh as an empty Secure Note, matching the exact
    /// item shape `bw create item` expects (verified against
    /// bitwarden/clients' CipherExport/SecureNoteExport templates).
    fn create_note(&self) -> anyhow::Result<std::process::Output> {
        cprintln(
            &format!("Bitwarden note '{}' not found — creating it...", self.note),
            &LogLevel::Info,
        );
        let template = serde_json::json!({
            "organizationId": null,
            "folderId": null,
            "type": 2,
            "name": self.note,
            "notes": "",
            "favorite": false,
            "fields": [],
            "reprompt": 0,
            "secureNote": { "type": 0 },
            "collectionIds": []
        });
        let encoded = run_bw_encode(&serde_json::to_string(&template)?)?;
        let output = self.bw(&["create", "item", &encoded])?;
        if !output.status.success() || output.stdout.is_empty() {
            anyhow::bail!(
                "Failed to create Bitwarden secure note '{}': {}",
                self.note,
                String::from_utf8_lossy(&output.stderr).trim()
            );
        }
        Ok(output)
    }

    /// Runs a `bw` subcommand non-interactively, with `--session` appended
    /// if we already have one cached from `authenticate_interactively`.
    fn bw(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
        let mut cmd = Command::new("bw");
        cmd.args(args);
        if let Some(session) = self.session.as_ref() {
            cmd.args(["--session", session]);
        }
        cmd.stdin(Stdio::null())
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to run `bw {}`: {e}", args.join(" ")))
    }

    /// Pulls the latest vault data from the server before we read/write,
    /// so a note edited elsewhere (another device, the web vault) isn't
    /// missed by a stale local cache. Best-effort and silent: called
    /// speculatively before we necessarily know we're authenticated, so a
    /// failure here isn't itself noteworthy - a real problem still
    /// surfaces properly from the get/set call that follows it.
    fn sync(&self) {
        let _ = self.bw(&["sync"]);
    }

    /// Drives `bw login`/`bw unlock` interactively and caches the
    /// resulting session for subsequent `bw` calls.
    fn authenticate_interactively(&mut self) -> anyhow::Result<()> {
        let status_output = Command::new("bw")
            .arg("status")
            .stdin(Stdio::null())
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to run `bw status`: {e}"))?;
        let status: serde_json::Value =
            serde_json::from_slice(&status_output.stdout).unwrap_or(serde_json::Value::Null);

        if status.get("status").and_then(|s| s.as_str()) == Some("unauthenticated") {
            cprintln(
                "Not logged in to Bitwarden — running `bw login`...",
                &LogLevel::Info,
            );
            let login_ok = Command::new("bw")
                .arg("login")
                .stdin(Stdio::inherit())
                .stdout(Stdio::inherit())
                .stderr(Stdio::inherit())
                .status()
                .map_err(|e| anyhow::anyhow!("Failed to run `bw login`: {e}"))?
                .success();
            if !login_ok {
                anyhow::bail!("`bw login` did not complete successfully");
            }
        }

        cprintln("Unlocking your Bitwarden vault...", &LogLevel::Info);
        // Prompt text goes to stderr (inherited, so it's visible); the raw
        // session key on success is the only thing on stdout (captured).
        let unlock_output = Command::new("bw")
            .args(["unlock", "--raw"])
            .stdin(Stdio::inherit())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .output()
            .map_err(|e| anyhow::anyhow!("Failed to run `bw unlock`: {e}"))?;
        if !unlock_output.status.success() || unlock_output.stdout.is_empty() {
            anyhow::bail!("`bw unlock` did not complete successfully");
        }
        self.session = Some(
            String::from_utf8_lossy(&unlock_output.stdout)
                .trim()
                .to_string(),
        );
        // Fresh session — pull the latest vault data before reading/writing,
        // so a note edited elsewhere isn't missed by a stale local cache.
        self.sync();
        Ok(())
    }

    fn ensure_loaded(&mut self) -> anyhow::Result<()> {
        if self.state.is_some() {
            return Ok(());
        }

        // Covers the "already had a valid session" case (e.g. an exported
        // BW_SESSION) - if we end up authenticating below instead, that
        // path syncs again right after, once we know it's worth reporting
        // a failure.
        self.sync();

        let mut output = self.bw(&["get", "item", &self.note])?;
        if !output.status.success() || output.stdout.is_empty() {
            self.authenticate_interactively()
                .map_err(|e| self.auth_failed_hint(&e.to_string()))?;
            output = self.bw(&["get", "item", &self.note])?;
        }
        if !output.status.success() || output.stdout.is_empty() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            // Only auto-create on a genuine "doesn't exist yet" - anything
            // else (ambiguous name match, permissions, ...) must surface as
            // an error instead, or a lookup failure for any other reason
            // would silently spawn a brand-new empty note, shadowing
            // whatever real note/values you already had.
            if stderr.trim() == "Not found." {
                output = self.create_note()?;
            } else {
                anyhow::bail!(
                    "`bw get item {}` failed: {}\nThis isn't a \"note doesn't exist yet\" \
case, so dotr won't create a new note automatically - fix the underlying issue (e.g. \
if multiple items are named '{}', rename or delete the extras so the name is unique) \
and try again.",
                    self.note,
                    stderr.trim(),
                    self.note
                );
            }
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        let envelope: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| {
            anyhow::anyhow!(
                "Failed to parse `bw get item {}` output as JSON: {e}",
                self.note
            )
        })?;
        let id = envelope
            .get("id")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("Bitwarden item '{}' has no id", self.note))?
            .to_string();
        let notes = envelope.get("notes").and_then(|v| v.as_str()).unwrap_or("");
        let values: Table = if notes.trim().is_empty() {
            Table::new()
        } else {
            toml::from_str(notes).map_err(|e| {
                anyhow::anyhow!(
                    "Bitwarden note '{}' doesn't contain valid `key = \"value\"` TOML content \
(the same format as .uservariables.toml): {e}",
                    self.note
                )
            })?
        };

        self.state = Some(BitwardenState {
            id,
            envelope,
            values,
        });
        Ok(())
    }
}

impl PromptStoreBackend for BitwardenStore {
    fn get(&mut self, _cwd: &Path, keys: &[String]) -> anyhow::Result<Table> {
        self.ensure_loaded()?;
        let values = &self.state.as_ref().expect("just ensured loaded").values;
        let mut records = Table::new();
        for key in keys {
            if let Some(v) = values.get(key) {
                records.insert(key.clone(), v.clone());
            }
        }
        Ok(records)
    }

    fn save(&mut self, _cwd: &Path, records: &Table) -> anyhow::Result<()> {
        self.ensure_loaded()?;

        let (id, encoded_envelope) = {
            let state = self.state.as_mut().expect("just ensured loaded");
            for (key, value) in records.iter() {
                state.values.insert(key.clone(), value.clone());
            }
            let notes_toml = toml::to_string(&state.values)?;
            state.envelope["notes"] = serde_json::Value::String(notes_toml);
            (state.id.clone(), serde_json::to_string(&state.envelope)?)
        };

        let encoded = run_bw_encode(&encoded_envelope)?;

        let mut edit_output = self.bw(&["edit", "item", &id, &encoded])?;
        if !edit_output.status.success() {
            self.authenticate_interactively()?;
            edit_output = self.bw(&["edit", "item", &id, &encoded])?;
            if !edit_output.status.success() {
                anyhow::bail!(
                    "`bw edit item` failed: {}",
                    String::from_utf8_lossy(&edit_output.stderr).trim()
                );
            }
        }
        Ok(())
    }
}

impl Drop for BitwardenStore {
    fn drop(&mut self) {
        // Only lock back up if we're the ones who unlocked it — a
        // pre-existing BW_SESSION from the user's own shell is left alone.
        if self.session.is_some() {
            cprintln("Locking your Bitwarden vault...", &LogLevel::Info);
            match self.bw(&["lock"]) {
                Ok(output) if !output.status.success() => cprintln(
                    &format!(
                        "Failed to re-lock the Bitwarden vault: {}",
                        String::from_utf8_lossy(&output.stderr).trim()
                    ),
                    &LogLevel::Warning,
                ),
                Err(e) => cprintln(
                    &format!("Failed to re-lock the Bitwarden vault: {e}"),
                    &LogLevel::Warning,
                ),
                _ => {}
            }
        }
    }
}

fn run_bw_encode(json: &str) -> anyhow::Result<String> {
    let mut child = Command::new("bw")
        .arg("encode")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| anyhow::anyhow!("Failed to run `bw encode`: {e}"))?;
    child
        .stdin
        .take()
        .expect("stdin was piped")
        .write_all(json.as_bytes())?;
    let output = child
        .wait_with_output()
        .map_err(|e| anyhow::anyhow!("Failed to run `bw encode`: {e}"))?;
    if !output.status.success() {
        anyhow::bail!(
            "`bw encode` failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}