Skip to main content

passless_rs/storage/
pass_refresh.rs

1use crate::storage::index::load_credential_paths;
2use crate::storage::pass::{GpgBackend, PassStorageAdapter as InnerPassStorageAdapter};
3use crate::storage::{CredentialFilter, CredentialStorage};
4use git2::{Oid, Repository};
5use log::{debug, warn};
6use passless_core::error::Result;
7use std::collections::hash_map::DefaultHasher;
8use std::fs;
9use std::hash::{Hash, Hasher};
10use std::path::{Path, PathBuf};
11use std::time::UNIX_EPOCH;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14struct RepositoryGeneration {
15    head: Option<Oid>,
16    credential_tree_stamp: u64,
17}
18
19/// Pass storage wrapper that keeps the in-memory credential index coherent with
20/// repository changes performed by another process while Passless is running.
21///
22/// The underlying pass adapter builds its index once during construction and
23/// updates it for writes performed through that adapter. External `git pull`,
24/// checkout, or direct credential-file changes bypass those update hooks. This
25/// wrapper tracks the Git HEAD plus a cheap metadata fingerprint of `fido2/*.gpg`
26/// files and recreates the underlying adapter whenever either changes.
27/// Recreating the adapter rescans credential paths without decrypting them and
28/// clears the decrypted credential cache, preventing stale file contents from
29/// surviving an external update.
30pub struct PassStorageAdapter {
31    inner: InnerPassStorageAdapter,
32    store_path: PathBuf,
33    path: PathBuf,
34    gpg_backend: GpgBackend,
35    allow_create_without_prompt: bool,
36    indexed_generation: RepositoryGeneration,
37}
38
39impl PassStorageAdapter {
40    pub fn new_with_options(
41        store_path: PathBuf,
42        path: PathBuf,
43        gpg_backend: GpgBackend,
44        allow_create_without_prompt: bool,
45    ) -> Result<Self> {
46        let inner = InnerPassStorageAdapter::new_with_options(
47            store_path.clone(),
48            path.clone(),
49            gpg_backend,
50            allow_create_without_prompt,
51        )?;
52        let indexed_generation = repository_generation(&store_path, &path);
53
54        Ok(Self {
55            inner,
56            store_path,
57            path,
58            gpg_backend,
59            allow_create_without_prompt,
60            indexed_generation,
61        })
62    }
63
64    fn reload_from_repository(&mut self, generation: RepositoryGeneration) -> Result<()> {
65        debug!(
66            "Reloading pass credential index after repository change: {:?} -> {:?}",
67            self.indexed_generation, generation
68        );
69
70        let inner = InnerPassStorageAdapter::new_with_options(
71            self.store_path.clone(),
72            self.path.clone(),
73            self.gpg_backend,
74            self.allow_create_without_prompt,
75        )?;
76        self.inner = inner;
77        // The constructor may pull newer remote state, so sample again after it returns.
78        self.indexed_generation = repository_generation(&self.store_path, &self.path);
79        Ok(())
80    }
81
82    fn refresh_if_repository_changed(&mut self) -> Result<()> {
83        let generation = repository_generation(&self.store_path, &self.path);
84        if generation != self.indexed_generation {
85            self.reload_from_repository(generation)?;
86        }
87        Ok(())
88    }
89
90    fn record_current_generation(&mut self) {
91        self.indexed_generation = repository_generation(&self.store_path, &self.path);
92    }
93
94    fn current_credential_count(&self) -> usize {
95        let generation = repository_generation(&self.store_path, &self.path);
96        if generation == self.indexed_generation {
97            return self.inner.count_credentials();
98        }
99
100        load_credential_paths(&self.store_path.join(&self.path), "gpg")
101            .map(|indexes| indexes.id.len())
102            .unwrap_or_else(|error| {
103                warn!(
104                    "Failed to count credentials from changed repository state: {}",
105                    error
106                );
107                self.inner.count_credentials()
108            })
109    }
110}
111
112impl CredentialStorage for PassStorageAdapter {
113    fn read_first(
114        &mut self,
115        filter: CredentialFilter,
116    ) -> soft_fido2::Result<soft_fido2::Credential> {
117        self.refresh_if_repository_changed()
118            .map_err(soft_fido2::Error::from)?;
119        self.inner.read_first(filter)
120    }
121
122    fn read_next(&mut self) -> soft_fido2::Result<soft_fido2::Credential> {
123        // Keep an in-progress iteration stable. The next read_first call will
124        // reconcile any repository change that happened during this iteration.
125        self.inner.read_next()
126    }
127
128    fn read(&mut self, id: &[u8]) -> soft_fido2::Result<soft_fido2::Credential> {
129        self.refresh_if_repository_changed()
130            .map_err(soft_fido2::Error::from)?;
131        self.inner.read(id)
132    }
133
134    fn write(&mut self, cred: soft_fido2::CredentialRef) -> soft_fido2::Result<()> {
135        self.refresh_if_repository_changed()
136            .map_err(soft_fido2::Error::from)?;
137        let result = self.inner.write(cred);
138        if result.is_ok() {
139            self.record_current_generation();
140        }
141        result
142    }
143
144    fn delete(&mut self, id: &[u8]) -> soft_fido2::Result<()> {
145        self.refresh_if_repository_changed()
146            .map_err(soft_fido2::Error::from)?;
147        let result = self.inner.delete(id);
148        if result.is_ok() {
149            self.record_current_generation();
150        }
151        result
152    }
153
154    fn count_credentials(&self) -> usize {
155        self.current_credential_count()
156    }
157
158    fn disable_user_verification(&self) -> bool {
159        self.inner.disable_user_verification()
160    }
161
162    fn cleanup_expired_cache(&mut self) {
163        if let Err(error) = self.refresh_if_repository_changed() {
164            warn!(
165                "Failed to refresh pass credential index after repository change: {}",
166                error
167            );
168        }
169        self.inner.cleanup_expired_cache();
170    }
171}
172
173fn repository_generation(store_path: &Path, credential_path: &Path) -> RepositoryGeneration {
174    RepositoryGeneration {
175        head: repository_head(store_path),
176        credential_tree_stamp: credential_tree_stamp(&store_path.join(credential_path)),
177    }
178}
179
180fn repository_head(store_path: &Path) -> Option<Oid> {
181    let repository = Repository::open(store_path)
182        .or_else(|_| Repository::discover(store_path))
183        .ok()?;
184    repository.head().ok()?.target()
185}
186
187fn credential_tree_stamp(root: &Path) -> u64 {
188    let mut files = Vec::new();
189    let Ok(rp_entries) = fs::read_dir(root) else {
190        return 0;
191    };
192
193    for rp_entry in rp_entries.flatten() {
194        let rp_path = rp_entry.path();
195        if !rp_path.is_dir() {
196            continue;
197        }
198
199        let Ok(credentials) = fs::read_dir(&rp_path) else {
200            continue;
201        };
202        for credential in credentials.flatten() {
203            let path = credential.path();
204            if path.extension().and_then(|value| value.to_str()) == Some("gpg") {
205                files.push(path);
206            }
207        }
208    }
209
210    files.sort();
211    let mut hasher = DefaultHasher::new();
212    for path in files {
213        path.strip_prefix(root).unwrap_or(&path).hash(&mut hasher);
214        if let Ok(metadata) = fs::metadata(&path) {
215            metadata.len().hash(&mut hasher);
216            if let Ok(modified) = metadata.modified()
217                && let Ok(duration) = modified.duration_since(UNIX_EPOCH)
218            {
219                duration.as_nanos().hash(&mut hasher);
220            }
221        }
222    }
223    hasher.finish()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use git2::Signature;
230    use std::fs;
231
232    fn commit_file(repository: &Repository, relative_path: &Path, contents: &[u8]) -> Oid {
233        let workdir = repository.workdir().unwrap();
234        let path = workdir.join(relative_path);
235        fs::create_dir_all(path.parent().unwrap()).unwrap();
236        fs::write(&path, contents).unwrap();
237
238        let mut index = repository.index().unwrap();
239        index.add_path(relative_path).unwrap();
240        index.write().unwrap();
241        let tree_id = index.write_tree().unwrap();
242        let tree = repository.find_tree(tree_id).unwrap();
243        let signature = Signature::now("Passless Test", "passless@example.invalid").unwrap();
244
245        let parents = repository
246            .head()
247            .ok()
248            .and_then(|head| head.target())
249            .and_then(|oid| repository.find_commit(oid).ok())
250            .into_iter()
251            .collect::<Vec<_>>();
252        let parent_refs = parents.iter().collect::<Vec<_>>();
253
254        repository
255            .commit(
256                Some("HEAD"),
257                &signature,
258                &signature,
259                "test commit",
260                &tree,
261                &parent_refs,
262            )
263            .unwrap()
264    }
265
266    #[test]
267    fn generation_changes_when_git_head_moves() {
268        let temp = tempfile::tempdir().unwrap();
269        let repository = Repository::init(temp.path()).unwrap();
270        commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
271        let first = repository_generation(temp.path(), Path::new("fido2"));
272
273        commit_file(
274            &repository,
275            Path::new("fido2/example.com/02.gpg"),
276            b"second",
277        );
278        let second = repository_generation(temp.path(), Path::new("fido2"));
279
280        assert_ne!(first.head, second.head);
281        assert_ne!(first, second);
282    }
283
284    #[test]
285    fn generation_changes_for_uncommitted_credential_changes() {
286        let temp = tempfile::tempdir().unwrap();
287        let repository = Repository::init(temp.path()).unwrap();
288        commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
289        let first = repository_generation(temp.path(), Path::new("fido2"));
290
291        fs::write(
292            temp.path().join("fido2/example.com/01.gpg"),
293            b"changed payload with different length",
294        )
295        .unwrap();
296        let second = repository_generation(temp.path(), Path::new("fido2"));
297
298        assert_eq!(first.head, second.head);
299        assert_ne!(first.credential_tree_stamp, second.credential_tree_stamp);
300    }
301
302    #[test]
303    fn generation_changes_when_uncommitted_credential_is_added_or_removed() {
304        let temp = tempfile::tempdir().unwrap();
305        let repository = Repository::init(temp.path()).unwrap();
306        commit_file(&repository, Path::new("fido2/example.com/01.gpg"), b"first");
307        let first = repository_generation(temp.path(), Path::new("fido2"));
308
309        let added = temp.path().join("fido2/example.com/02.gpg");
310        fs::write(&added, b"second").unwrap();
311        let second = repository_generation(temp.path(), Path::new("fido2"));
312        assert_ne!(first.credential_tree_stamp, second.credential_tree_stamp);
313
314        fs::remove_file(added).unwrap();
315        let third = repository_generation(temp.path(), Path::new("fido2"));
316        assert_eq!(first.credential_tree_stamp, third.credential_tree_stamp);
317    }
318}