1use 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
30pub struct PassProvider {
32 runner: Arc<dyn CommandRunner>,
33 store_dir: PathBuf,
37}
38
39impl PassProvider {
40 pub fn new(runner: Arc<dyn CommandRunner>, store_dir: PathBuf) -> Self {
43 Self { runner, store_dir }
44 }
45
46 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 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 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 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 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 format!(
159 "`pass show {reference}` failed (exit {}): {stderr}",
160 out.exit_code
161 )
162 };
163 return Err(DodotError::Other(err_msg));
164 }
165 let first_line = out.stdout.split('\n').next().unwrap_or("");
170 Ok(SecretString::new(first_line.to_string()))
171 }
172}
173
174fn 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 type ScriptedResponse = (
189 String,
190 Vec<String>,
191 std::result::Result<CommandOutput, String>,
192 );
193
194 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 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 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); 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}