prs_lib/
store.rs

1//! Interface to a password store and its secrets.
2
3use std::ffi::OsString;
4use std::fs;
5use std::path::{self, Path, PathBuf};
6
7use anyhow::{Result, ensure};
8use thiserror::Error;
9use walkdir::{DirEntry, WalkDir};
10
11#[cfg(all(feature = "tomb", target_os = "linux"))]
12use crate::tomb::Tomb;
13use crate::{
14    Recipients,
15    crypto::{self, prelude::*},
16    sync::Sync,
17};
18
19/// Password store secret file suffix.
20pub const SECRET_SUFFIX: &str = ".gpg";
21
22/// Represents a password store.
23#[derive(Clone)]
24pub struct Store {
25    /// Root directory of the password store.
26    ///
27    /// This path is always absolute.
28    pub root: PathBuf,
29}
30
31impl Store {
32    /// Open a store at the given path.
33    pub fn open<P: AsRef<str>>(root: P) -> Result<Self> {
34        let root: PathBuf = shellexpand::full(&root)
35            .map_err(Err::ExpandPath)?
36            .as_ref()
37            .into();
38        let root = root.canonicalize().map_err(Err::CanonicalizePath)?;
39
40        // Make sure store directory exists
41        ensure!(root.is_dir(), Err::NoRootDir(root));
42
43        // TODO: check if .gpg-ids exists? this does not work if this is a tomb
44
45        Ok(Self { root })
46    }
47
48    /// Get the recipient keys for this store.
49    pub fn recipients(&self) -> Result<Recipients> {
50        Recipients::load(self)
51    }
52
53    /// Get a sync helper for this store.
54    pub fn sync(&self) -> Sync<'_> {
55        Sync::new(self)
56    }
57
58    /// Get a tomb helper for this store.
59    #[cfg(all(feature = "tomb", target_os = "linux"))]
60    pub fn tomb(&self, quiet: bool, verbose: bool, force: bool) -> Tomb<'_> {
61        Tomb::new(self, quiet, verbose, force)
62    }
63
64    /// Create secret iterator for this store.
65    pub fn secret_iter(&self) -> SecretIter {
66        self.secret_iter_config(SecretIterConfig::default())
67    }
68
69    /// Create secret iterator for this store with custom configuration.
70    pub fn secret_iter_config(&self, config: SecretIterConfig) -> SecretIter {
71        SecretIter::new(self.root.clone(), config)
72    }
73
74    /// List store password secrets.
75    pub fn secrets(&self, filter: Option<String>) -> Vec<Secret> {
76        self.secret_iter().filter_name(filter).collect()
77    }
78
79    /// Try to find matching secret at path.
80    pub fn find_at(&self, path: &str) -> Option<Secret> {
81        // Build path
82        let path = self.root.as_path().join(path);
83        let path = path.to_str()?;
84
85        // Try path with secret file suffix
86        let with_suffix = PathBuf::from(format!("{path}{SECRET_SUFFIX}"));
87        if with_suffix.is_file() {
88            return Some(Secret::from(self, with_suffix));
89        }
90
91        // Try path without secret file suffix
92        let without_suffix = Path::new(path);
93        if without_suffix.is_file() {
94            return Some(Secret::from(self, without_suffix.to_path_buf()));
95        }
96
97        None
98    }
99
100    /// Try to find matching secrets for given query.
101    ///
102    /// If secret is found at exact query path, `FindSecret::Found` is returned.
103    /// Otherwise any number of closely matching secrets is returned as `FindSecret::Many`.
104    pub fn find(&self, query: Option<String>) -> FindSecret {
105        // Try to find exact secret match
106        if let Some(query) = &query
107            && let Some(secret) = self.find_at(query)
108        {
109            return FindSecret::Exact(secret);
110        }
111
112        // Find all closely matching
113        FindSecret::Many(self.secrets(query))
114    }
115
116    /// Normalizes a path for a secret in this store.
117    ///
118    /// - Ensures path is within store.
119    /// - If directory is given, name hint is appended.
120    /// - Sets correct extension.
121    /// - Creates parent directories if non existant (optional).
122    pub fn normalize_secret_path<P: AsRef<Path>>(
123        &self,
124        target: P,
125        name_hint: Option<&str>,
126        create_dirs: bool,
127    ) -> Result<PathBuf> {
128        // Take target as base path
129        let mut path = PathBuf::from(target.as_ref());
130
131        // Expand path
132        if let Some(path_str) = path.to_str() {
133            path = PathBuf::from(
134                shellexpand::full(path_str)
135                    .map_err(Err::ExpandPath)?
136                    .as_ref(),
137            );
138        }
139
140        let target_is_dir = path.is_dir()
141            || target
142                .as_ref()
143                .to_str()
144                .and_then(|s| s.chars().last())
145                .map(path::is_separator)
146                .unwrap_or(false);
147
148        // Strip store prefix
149        if let Ok(tmp) = path.strip_prefix(&self.root) {
150            path = tmp.into();
151        }
152
153        // Make relative
154        if path.is_absolute() {
155            path = PathBuf::from(format!(".{}{}", path::MAIN_SEPARATOR, path.display()));
156        }
157
158        // Prefix store root
159        path = self.root.as_path().join(path);
160
161        // Add current secret name if target is dir
162        if target_is_dir {
163            path.push(name_hint.ok_or_else(|| Err::TargetDirWithoutNamehint(path.clone()))?);
164        }
165
166        // Add secret extension if non existent
167        let ext: OsString = SECRET_SUFFIX.trim_start_matches('.').into();
168        if path.extension() != Some(&ext) {
169            let mut tmp = path.as_os_str().to_owned();
170            tmp.push(SECRET_SUFFIX);
171            path = PathBuf::from(tmp);
172        }
173
174        // Create parent dir if it doesn't exist
175        if create_dirs {
176            let parent = path.parent().unwrap();
177            if !parent.is_dir() {
178                fs::create_dir_all(parent).map_err(Err::CreateDir)?;
179            }
180        }
181
182        Ok(path)
183    }
184}
185
186/// Find secret result.
187pub enum FindSecret {
188    /// Found exact secret match.
189    Exact(Secret),
190
191    /// Found any number of non-exact secret matches.
192    Many(Vec<Secret>),
193}
194
195/// A password store secret.
196#[derive(Debug, Clone)]
197pub struct Secret {
198    /// Display name of the secret, relative path to the password store root.
199    pub name: String,
200
201    /// Full path to the password store secret.
202    pub path: PathBuf,
203}
204
205impl Secret {
206    /// Construct secret at given full path from given store.
207    pub fn from(store: &Store, path: PathBuf) -> Self {
208        Self::in_root(&store.root, path)
209    }
210
211    /// Construct secret at given path in the given password store root.
212    pub fn in_root(root: &Path, path: PathBuf) -> Self {
213        let name: String = relative_path(root, &path)
214            .ok()
215            .and_then(|f| f.to_str())
216            .map(|f| f.trim_end_matches(SECRET_SUFFIX))
217            .unwrap_or_else(|| "?")
218            .to_string();
219        Self { name, path }
220    }
221
222    /// Get relative path to this secret, root must be given.
223    pub fn relative_path<'a>(
224        &'a self,
225        root: &'a Path,
226    ) -> Result<&'a Path, std::path::StripPrefixError> {
227        relative_path(root, &self.path)
228    }
229
230    /// Returns pointed to secret.
231    ///
232    /// If this secret is an alias, this will return the pointed to secret.
233    /// If this secret is not an alias, an error will be returned.
234    ///
235    /// The pointed to secret may be an alias as well.
236    pub fn alias_target(&self, store: &Store) -> Result<Secret> {
237        // Read alias target path, make absolute, attempt to canonicalize
238        let mut path = self.path.parent().unwrap().join(fs::read_link(&self.path)?);
239        if let Ok(canonical_path) = path.canonicalize() {
240            path = canonical_path;
241        }
242
243        Ok(Secret::from(store, path))
244    }
245}
246
247/// Get relative path in given root.
248pub fn relative_path<'a>(
249    root: &'a Path,
250    path: &'a Path,
251) -> Result<&'a Path, std::path::StripPrefixError> {
252    path.strip_prefix(root)
253}
254
255/// Secret iterator configuration.
256///
257/// Used to configure what files are found by the secret iterator.
258#[derive(Clone, Debug)]
259pub struct SecretIterConfig {
260    /// Find pure files.
261    pub find_files: bool,
262
263    /// Find files that are symlinks.
264    ///
265    /// Will still find files if they're symlinked to while `find_files` is `false`.
266    pub find_symlink_files: bool,
267}
268
269impl Default for SecretIterConfig {
270    fn default() -> Self {
271        Self {
272            find_files: true,
273            find_symlink_files: true,
274        }
275    }
276}
277
278/// Iterator that walks through password store secrets.
279///
280/// This walks all password store directories, and yields password secrets.
281/// Hidden files or directories are skipped.
282pub struct SecretIter {
283    /// Root of the store to walk.
284    root: PathBuf,
285
286    /// Directory walker.
287    walker: Box<dyn Iterator<Item = DirEntry>>,
288}
289
290impl SecretIter {
291    /// Create new store secret iterator at given store root.
292    pub fn new(root: PathBuf, config: SecretIterConfig) -> Self {
293        let walker = WalkDir::new(&root)
294            .follow_links(true)
295            .into_iter()
296            .filter_entry(|e| !is_hidden_subdir(e))
297            .filter_map(|e| e.ok())
298            .filter(is_secret_file)
299            .filter(move |entry| filter_by_config(entry, &config));
300        Self {
301            root,
302            walker: Box::new(walker),
303        }
304    }
305
306    /// Transform into a filtered secret iterator.
307    pub fn filter_name(self, filter: Option<String>) -> FilterSecretIter<Self> {
308        FilterSecretIter::new(self, filter)
309    }
310}
311
312impl Iterator for SecretIter {
313    type Item = Secret;
314
315    fn next(&mut self) -> Option<Self::Item> {
316        self.walker
317            .next()
318            .map(|e| Secret::in_root(&self.root, e.path().into()))
319    }
320}
321
322/// Check if given WalkDir DirEntry is hidden sub-directory.
323fn is_hidden_subdir(entry: &DirEntry) -> bool {
324    entry.depth() > 0
325        && entry
326            .file_name()
327            .to_str()
328            .map(|s| s.starts_with('.') || s == "lost+found")
329            .unwrap_or(false)
330}
331
332/// Check if given WalkDir DirEntry is a secret file.
333fn is_secret_file(entry: &DirEntry) -> bool {
334    entry.file_type().is_file()
335        && entry
336            .file_name()
337            .to_str()
338            .map(|s| s.ends_with(SECRET_SUFFIX))
339            .unwrap_or(false)
340}
341
342/// Check if given WalkDir DirEntry passes the configuration.
343fn filter_by_config(entry: &DirEntry, config: &SecretIterConfig) -> bool {
344    // Optimization, config permutation which includes all files
345    if config.find_files && config.find_symlink_files {
346        return true;
347    }
348
349    // Find symlinks
350    if config.find_symlink_files && entry.path_is_symlink() {
351        return true;
352    }
353
354    // Do not find symlinks
355    if !config.find_symlink_files && entry.path_is_symlink() {
356        return false;
357    }
358
359    // Find files
360    if !config.find_files && !entry.path_is_symlink() {
361        return false;
362    }
363
364    true
365}
366
367/// Check whether we can decrypt the first secret in the store.
368///
369/// If decryption fails, and this returns false, it means we don't own any compatible secret key.
370///
371/// Returns true if there is no secret.
372pub fn can_decrypt(store: &Store) -> bool {
373    // Try all proto's here once we support more
374    store
375        .secret_iter()
376        .next()
377        .map(|secret| {
378            crypto::context(&crate::CONFIG)
379                .map(|mut context| context.can_decrypt_file(&secret.path).unwrap_or(true))
380                .unwrap_or(false)
381        })
382        .unwrap_or(true)
383}
384
385/// Iterator that wraps a `SecretIter` with a filter.
386pub struct FilterSecretIter<I>
387where
388    I: Iterator<Item = Secret>,
389{
390    inner: I,
391    filter: Option<String>,
392}
393
394impl<I> FilterSecretIter<I>
395where
396    I: Iterator<Item = Secret>,
397{
398    /// Construct a new filter secret iterator.
399    pub fn new(inner: I, filter: Option<String>) -> Self {
400        Self { inner, filter }
401    }
402}
403
404impl<I> Iterator for FilterSecretIter<I>
405where
406    I: Iterator<Item = Secret>,
407{
408    type Item = Secret;
409
410    fn next(&mut self) -> Option<Self::Item> {
411        // Return all with no filter, or lowercase filter text
412        let filter = match &self.filter {
413            None => return self.inner.next(),
414            Some(filter) => filter.to_lowercase(),
415        };
416
417        self.inner
418            .find(|secret| secret.name.to_lowercase().contains(&filter))
419    }
420}
421
422/// Password store error.
423#[derive(Debug, Error)]
424pub enum Err {
425    #[error("failed to expand store root path")]
426    ExpandPath(#[source] shellexpand::LookupError<std::env::VarError>),
427
428    #[error("failed to canonicalize store root path")]
429    CanonicalizePath(#[source] std::io::Error),
430
431    #[error("failed to open password store, not a directory: {0}")]
432    NoRootDir(PathBuf),
433
434    #[error("failed to create directory")]
435    CreateDir(#[source] std::io::Error),
436
437    #[error("cannot use directory as target without name hint")]
438    TargetDirWithoutNamehint(PathBuf),
439}