Skip to main content

dodot_lib/secret/
pass.rs

1//! `pass` provider — password-store integration.
2//!
3//! Reference shape: `pass:path/to/entry` (single-colon, no slashes
4//! after the colon for the scheme prefix; provider sees the literal
5//! path, e.g. `path/to/entry`).
6//!
7//! Resolution: shells out to `pass show <path>`. By convention,
8//! `pass` entries' first line is the password; subsequent lines are
9//! arbitrary metadata. dodot follows this convention — `resolve()`
10//! returns the first line, stripped of trailing `\n`.
11//!
12//! `pass` itself defers crypto to GPG; the gpg-agent dance handles
13//! interactive auth (passphrase prompts, smartcard touches) before
14//! `pass show` returns. Our `probe()` verifies that the binary is on
15//! PATH and that `$PASSWORD_STORE_DIR` (or `~/.password-store`) is
16//! initialised; deeper auth-state checks (gpg key access) are
17//! deferred to resolve-time because probing them would mean
18//! triggering the very prompt we're trying to gate behind probe().
19//!
20//! See `secrets.lex` §5.2 for the spec table.
21
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use crate::datastore::CommandRunner;
26use crate::secret::provider::{ProbeResult, SecretProvider};
27use crate::secret::secret_string::SecretString;
28use crate::{DodotError, Result};
29
30/// `SecretProvider` impl for password-store.
31pub struct PassProvider {
32    runner: Arc<dyn CommandRunner>,
33    /// Directory to check for store initialisation. Defaults to
34    /// `$PASSWORD_STORE_DIR` if set, falling back to `~/.password-store`.
35    /// Tests inject a hermetic path here.
36    store_dir: PathBuf,
37}
38
39impl PassProvider {
40    /// Construct with a runner and explicit store directory. Tests
41    /// use this directly; production code uses [`Self::from_env`].
42    pub fn new(runner: Arc<dyn CommandRunner>, store_dir: PathBuf) -> Self {
43        Self { runner, store_dir }
44    }
45
46    /// Construct from environment: respects `$PASSWORD_STORE_DIR`,
47    /// falls back to `$HOME/.password-store`. If `$HOME` is unset
48    /// (deeply unusual; suggests a test or a daemon context),
49    /// returns a provider rooted at `/.password-store` — `probe()`
50    /// will surface `Misconfigured` because that path won't exist.
51    pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
52        let store_dir = std::env::var_os("PASSWORD_STORE_DIR")
53            .map(PathBuf::from)
54            .unwrap_or_else(|| {
55                let mut p = std::env::var_os("HOME")
56                    .map(PathBuf::from)
57                    .unwrap_or_else(|| PathBuf::from("/"));
58                p.push(".password-store");
59                p
60            });
61        Self::new(runner, store_dir)
62    }
63
64    /// Validate the reference shape before shelling out. Empty
65    /// references and references whose path segments include `..`
66    /// (path traversal) are rejected up-front. We don't try to be
67    /// clever about shell-quoting because `CommandRunner::run` takes
68    /// argv as a slice — there's no shell interpolation in play.
69    ///
70    /// We check segment-equality instead of a substring match on
71    /// `..` so that legitimate entry names containing two
72    /// consecutive dots (e.g. `service-foo..staging`) pass through.
73    /// Pass entries are stored as files on disk; only `..` as its
74    /// own segment escapes the store root.
75    fn validate_reference(reference: &str) -> Result<()> {
76        if reference.is_empty() {
77            return Err(DodotError::Other(
78                "pass reference is empty. Expected `pass:path/to/entry`.".into(),
79            ));
80        }
81        if reference.split('/').any(|seg| seg == "..") {
82            return Err(DodotError::Other(format!(
83                "pass reference `{reference}` contains a `..` path segment — \
84                 path-traversal references are refused for safety. \
85                 Use the literal entry path under the store root."
86            )));
87        }
88        Ok(())
89    }
90}
91
92impl SecretProvider for PassProvider {
93    fn scheme(&self) -> &str {
94        "pass"
95    }
96
97    fn probe(&self) -> ProbeResult {
98        // Cheap binary-on-PATH check: `pass version` returns 0 with
99        // a banner. `pass --help` would also work; `version` is the
100        // canonical "I'm here" probe across most tool conventions.
101        match self.runner.run("pass", &["version".into()]) {
102            Ok(out) if out.exit_code == 0 => {}
103            Ok(_) => {
104                return ProbeResult::ProbeFailed {
105                    details: "`pass version` returned a non-zero exit code; the \
106                              binary is on PATH but not behaving as expected"
107                        .into(),
108                };
109            }
110            Err(_) => {
111                return ProbeResult::NotInstalled {
112                    hint: "install pass: https://www.passwordstore.org/ \
113                           (e.g. `apt install pass`, `brew install pass`)"
114                        .into(),
115                };
116            }
117        }
118        // Initialised-store check: a `.gpg-id` file at the store
119        // root is `pass init`'s canonical artifact.
120        let gpg_id = self.store_dir.join(".gpg-id");
121        if !gpg_id.exists() {
122            return ProbeResult::Misconfigured {
123                hint: format!(
124                    "password store not initialised at {} \
125                     (no .gpg-id found). \
126                     Run `pass init <gpg-key-id>`, or set \
127                     $PASSWORD_STORE_DIR to point at an existing store.",
128                    self.store_dir.display()
129                ),
130            };
131        }
132        ProbeResult::Ok
133    }
134
135    fn resolve(&self, reference: &str) -> Result<SecretString> {
136        Self::validate_reference(reference)?;
137        let out = self
138            .runner
139            .run("pass", &["show".into(), reference.into()])?;
140        if out.exit_code != 0 {
141            // Map the most common "entry not found" pattern to a
142            // sharper error. `pass show missing/path` exits 1 and
143            // prints `Error: missing/path is not in the password
144            // store.` to stderr. Detect by exit + a stable phrase.
145            let stderr = out.stderr.trim();
146            let err_msg = if stderr.contains("not in the password store") {
147                format!(
148                    "secret `pass:{reference}` not found in the password store. \
149                     Verify the entry: `pass ls {}`",
150                    parent_path(reference).unwrap_or("/")
151                )
152            } else if stderr.is_empty() {
153                format!("`pass show {reference}` exited with code {}", out.exit_code)
154            } else {
155                // Provider stderr can carry gpg-agent diagnostics or
156                // similar. Surface verbatim — but `pass` doesn't echo
157                // the password to stderr, so this is safe to include.
158                format!(
159                    "`pass show {reference}` failed (exit {}): {stderr}",
160                    out.exit_code
161                )
162            };
163            return Err(DodotError::Other(err_msg));
164        }
165        // pass show emits: <password>\n[optional metadata lines]\n
166        // The first line is the password. Strip trailing `\n` only;
167        // a multi-line value (subsequent lines) is metadata, not
168        // password content.
169        let first_line = out.stdout.split('\n').next().unwrap_or("");
170        Ok(SecretString::new(first_line.to_string()))
171    }
172}
173
174/// Return everything before the last `/` in `reference`, or `None`
175/// if the reference is at the store root.
176fn parent_path(reference: &str) -> Option<&str> {
177    let idx = reference.rfind('/')?;
178    Some(&reference[..idx])
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::datastore::CommandOutput;
185    use std::sync::Mutex;
186
187    /// `(executable, args, response)` tuple used by `ScriptedRunner`.
188    type ScriptedResponse = (
189        String,
190        Vec<String>,
191        std::result::Result<CommandOutput, String>,
192    );
193
194    /// Test runner: maps `(executable, args)` to a canned outcome.
195    /// Records every call so probe-then-resolve flows can be
196    /// asserted against.
197    struct ScriptedRunner {
198        responses: Mutex<Vec<ScriptedResponse>>,
199        calls: Mutex<Vec<(String, Vec<String>)>>,
200    }
201
202    impl ScriptedRunner {
203        fn new() -> Self {
204            Self {
205                responses: Mutex::new(Vec::new()),
206                calls: Mutex::new(Vec::new()),
207            }
208        }
209        fn expect(
210            self,
211            exe: impl Into<String>,
212            args: Vec<String>,
213            response: std::result::Result<CommandOutput, String>,
214        ) -> Self {
215            self.responses
216                .lock()
217                .unwrap()
218                .push((exe.into(), args, response));
219            self
220        }
221        fn calls(&self) -> Vec<(String, Vec<String>)> {
222            self.calls.lock().unwrap().clone()
223        }
224    }
225
226    impl CommandRunner for ScriptedRunner {
227        fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
228            self.calls
229                .lock()
230                .unwrap()
231                .push((exe.to_string(), args.to_vec()));
232            let mut responses = self.responses.lock().unwrap();
233            if responses.is_empty() {
234                return Err(DodotError::Other(format!(
235                    "ScriptedRunner: unexpected call to `{exe} {args:?}` — no responses queued"
236                )));
237            }
238            let (expected_exe, expected_args, response) = responses.remove(0);
239            assert_eq!(exe, expected_exe, "executable mismatch");
240            assert_eq!(args, expected_args.as_slice(), "args mismatch");
241            response.map_err(DodotError::Other)
242        }
243    }
244
245    fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
246        Ok(CommandOutput {
247            exit_code: 0,
248            stdout: stdout.into(),
249            stderr: String::new(),
250        })
251    }
252
253    fn err(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
254        Ok(CommandOutput {
255            exit_code: exit,
256            stdout: String::new(),
257            stderr: stderr.into(),
258        })
259    }
260
261    fn make_store_dir(initialised: bool) -> tempfile::TempDir {
262        let dir = tempfile::tempdir().unwrap();
263        if initialised {
264            std::fs::write(dir.path().join(".gpg-id"), "test@example.invalid\n").unwrap();
265        }
266        dir
267    }
268
269    #[test]
270    fn scheme_is_pass() {
271        let dir = make_store_dir(true);
272        let p = PassProvider::new(Arc::new(ScriptedRunner::new()), dir.path().into());
273        assert_eq!(p.scheme(), "pass");
274    }
275
276    #[test]
277    fn resolve_returns_first_line_of_pass_show_output() {
278        let dir = make_store_dir(true);
279        let runner = Arc::new(ScriptedRunner::new().expect(
280            "pass",
281            vec!["show".into(), "personal/db".into()],
282            ok("hunter2\nuser: alice\nurl: https://db.example\n"),
283        ));
284        let p = PassProvider::new(runner, dir.path().into());
285        let s = p.resolve("personal/db").unwrap();
286        assert_eq!(s.expose().unwrap(), "hunter2");
287    }
288
289    #[test]
290    fn resolve_handles_value_without_trailing_newline() {
291        let dir = make_store_dir(true);
292        let runner = Arc::new(ScriptedRunner::new().expect(
293            "pass",
294            vec!["show".into(), "k".into()],
295            ok("no-newline-at-end"),
296        ));
297        let p = PassProvider::new(runner, dir.path().into());
298        assert_eq!(
299            p.resolve("k").unwrap().expose().unwrap(),
300            "no-newline-at-end"
301        );
302    }
303
304    #[test]
305    fn resolve_maps_not_in_store_to_actionable_error() {
306        let dir = make_store_dir(true);
307        let runner = Arc::new(ScriptedRunner::new().expect(
308            "pass",
309            vec!["show".into(), "missing/k".into()],
310            err(1, "Error: missing/k is not in the password store."),
311        ));
312        let p = PassProvider::new(runner, dir.path().into());
313        let e = p.resolve("missing/k").unwrap_err().to_string();
314        assert!(e.contains("`pass:missing/k` not found"));
315        // Lists the parent path so the user can run `pass ls <parent>`
316        // to spot a typo.
317        assert!(e.contains("`pass ls missing`"));
318    }
319
320    #[test]
321    fn resolve_other_failures_include_stderr_verbatim() {
322        let dir = make_store_dir(true);
323        let runner = Arc::new(ScriptedRunner::new().expect(
324            "pass",
325            vec!["show".into(), "k".into()],
326            err(2, "gpg: decryption failed: No secret key"),
327        ));
328        let p = PassProvider::new(runner, dir.path().into());
329        let e = p.resolve("k").unwrap_err().to_string();
330        assert!(e.contains("decryption failed"));
331        assert!(e.contains("(exit 2)"));
332    }
333
334    #[test]
335    fn resolve_rejects_empty_reference() {
336        let dir = make_store_dir(true);
337        let p = PassProvider::new(Arc::new(ScriptedRunner::new()), dir.path().into());
338        let e = p.resolve("").unwrap_err().to_string();
339        assert!(e.contains("empty"));
340    }
341
342    #[test]
343    fn resolve_rejects_dotdot_reference() {
344        let dir = make_store_dir(true);
345        let p = PassProvider::new(Arc::new(ScriptedRunner::new()), dir.path().into());
346        let e = p.resolve("../escape").unwrap_err().to_string();
347        assert!(e.contains("path-traversal"));
348    }
349
350    #[test]
351    fn resolve_rejects_dotdot_in_middle_segment() {
352        let dir = make_store_dir(true);
353        let p = PassProvider::new(Arc::new(ScriptedRunner::new()), dir.path().into());
354        let e = p.resolve("foo/../escape").unwrap_err().to_string();
355        assert!(e.contains("path-traversal"));
356    }
357
358    #[test]
359    fn resolve_accepts_double_dot_inside_a_segment() {
360        // `foo..bar` is a legitimate pass entry name (two consecutive
361        // dots within one segment, not a `..` path segment). The
362        // validator must let it through; the runner answers with the
363        // entry's value.
364        let dir = make_store_dir(true);
365        let runner = Arc::new(ScriptedRunner::new().expect(
366            "pass",
367            vec!["show".into(), "service-foo..staging".into()],
368            ok("hunter2\n"),
369        ));
370        let p = PassProvider::new(runner, dir.path().into());
371        let v = p.resolve("service-foo..staging").unwrap();
372        assert_eq!(v.expose().unwrap(), "hunter2");
373    }
374
375    #[test]
376    fn probe_ok_when_binary_present_and_store_initialised() {
377        let dir = make_store_dir(true);
378        let runner = Arc::new(ScriptedRunner::new().expect(
379            "pass",
380            vec!["version".into()],
381            ok("=============================================\n= pass: the standard unix password manager =\n"),
382        ));
383        let p = PassProvider::new(runner.clone(), dir.path().into());
384        assert!(matches!(p.probe(), ProbeResult::Ok));
385        assert_eq!(runner.calls().len(), 1);
386    }
387
388    #[test]
389    fn probe_not_installed_when_runner_errors() {
390        let dir = make_store_dir(true);
391        let runner = Arc::new(ScriptedRunner::new().expect(
392            "pass",
393            vec!["version".into()],
394            Err("command not found: pass".into()),
395        ));
396        let p = PassProvider::new(runner, dir.path().into());
397        match p.probe() {
398            ProbeResult::NotInstalled { hint } => {
399                assert!(hint.contains("install pass"));
400                assert!(hint.contains("apt install"));
401                assert!(hint.contains("brew install"));
402            }
403            other => panic!("expected NotInstalled, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn probe_misconfigured_when_store_uninitialised() {
409        let dir = make_store_dir(false); // no .gpg-id
410        let runner = Arc::new(ScriptedRunner::new().expect(
411            "pass",
412            vec!["version".into()],
413            ok("pass v1.7\n"),
414        ));
415        let p = PassProvider::new(runner, dir.path().into());
416        match p.probe() {
417            ProbeResult::Misconfigured { hint } => {
418                assert!(hint.contains("not initialised"));
419                assert!(hint.contains("pass init"));
420                assert!(hint.contains("PASSWORD_STORE_DIR"));
421            }
422            other => panic!("expected Misconfigured, got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn probe_failed_on_nonzero_version_exit() {
428        let dir = make_store_dir(true);
429        let runner =
430            Arc::new(ScriptedRunner::new().expect("pass", vec!["version".into()], err(127, "")));
431        let p = PassProvider::new(runner, dir.path().into());
432        match p.probe() {
433            ProbeResult::ProbeFailed { details } => {
434                assert!(details.contains("non-zero exit"));
435            }
436            other => panic!("expected ProbeFailed, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn parent_path_strips_last_segment() {
442        assert_eq!(parent_path("a/b/c"), Some("a/b"));
443        assert_eq!(parent_path("a/b"), Some("a"));
444        assert_eq!(parent_path("a"), None);
445        assert_eq!(parent_path(""), None);
446    }
447}