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
19pub 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 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 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}