1pub mod gpg_id;
4pub mod init;
5
6use crate::storage::credential::Credential;
7use crate::storage::index::{
8 CredentialCache, CredentialIndexes, CredentialPathInfo, get_credential_path,
9 load_credential_paths, update_indexes_on_delete, update_indexes_on_write,
10};
11use crate::storage::rp_id::validate_rp_id_for_storage;
12use crate::storage::{CredentialFilter, CredentialStorage};
13use crate::util::{bytes_to_hex, create_secure_dir_all};
14use passless_core::error::{Error, Result};
15
16use std::fmt::Display;
17use std::path::{Path, PathBuf};
18use std::time::Instant;
19
20use core::fmt;
21use log::{debug, error, info, warn};
22use prs_lib::crypto::IsContext;
23use prs_lib::{Ciphertext, Plaintext, Store};
24use zeroize::Zeroizing;
25
26pub struct PassStorageAdapter {
31 store_path: PathBuf,
32 path: PathBuf,
33 gpg_backend: GpgBackend,
34 indexes: CredentialIndexes,
35 cache: CredentialCache,
36 iteration_index: usize,
37 iteration_entries: Vec<PathBuf>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum GpgBackend {
43 Gpgme,
45 #[default]
47 GnupgBin,
48}
49
50impl std::str::FromStr for GpgBackend {
51 type Err = Error;
52
53 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
55 match s.to_lowercase().as_str() {
56 "gpgme" => Ok(Self::Gpgme),
57 "gnupg-bin" | "gnupg_bin" | "gnupg" => Ok(Self::GnupgBin),
58 _ => Err(Error::Config(format!(
59 "Invalid GPG backend: '{}'. Must be 'gpgme' or 'gnupg-bin'",
60 s
61 ))),
62 }
63 }
64}
65
66impl GpgBackend {
67 #[allow(dead_code)]
68 pub fn validate(&self) -> Result<()> {
69 match self {
70 Self::Gpgme => {
71 debug!("Validating GPGME backend configuration");
72 Ok(())
73 }
74 Self::GnupgBin => {
75 debug!("Validating GnuPG binary backend configuration");
76 use std::process::Command;
77 let result = Command::new("gpg").arg("--version").output();
78
79 match result {
80 Ok(output) if output.status.success() => {
81 debug!(
82 "GPG binary is available: {:?}",
83 String::from_utf8_lossy(&output.stdout)
84 );
85 Ok(())
86 }
87 _ => {
88 warn!("GPG binary not found or not executable");
89 Ok(()) }
91 }
92 }
93 }
94 }
95}
96
97impl Display for GpgBackend {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 GpgBackend::Gpgme => write!(f, "gpgme"),
101 GpgBackend::GnupgBin => write!(f, "gpg"),
102 }
103 }
104}
105
106impl PassStorageAdapter {
107 pub fn new_with_options(
108 store_path: PathBuf,
109 path: PathBuf,
110 gpg_backend: GpgBackend,
111 allow_create_without_prompt: bool,
112 ) -> Result<Self> {
113 let allow_create_without_prompt = cfg!(debug_assertions) && allow_create_without_prompt;
114 info!("Using pass (password-store) backend");
115 info!("Store path: {}", store_path.display());
116 info!("Path: {}", path.display());
117 info!("GPG backend: {}", gpg_backend);
118
119 debug!("Opening password store at: {:?}", store_path);
120
121 self::init::ensure_initialized(&store_path, gpg_backend, allow_create_without_prompt)?;
124
125 if !store_path.exists() {
126 return Err(Error::Storage(format!(
127 "Password store path does not exist: {:?}",
128 store_path
129 )));
130 }
131
132 debug!("Using GPG backend: {:?}", gpg_backend);
133
134 let mut adapter = Self {
135 store_path,
136 path,
137 gpg_backend,
138 indexes: Default::default(),
139 cache: Default::default(),
140 iteration_index: Default::default(),
141 iteration_entries: Default::default(),
142 };
143
144 adapter.sync_prepare()?;
146
147 adapter.indexes = load_credential_paths(&adapter.get_fido2_path(), "gpg")
149 .map_err(|e| Error::Storage(format!("Failed to load credential paths: {}", e)))?;
150
151 Ok(adapter)
152 }
153
154 fn get_fido2_path(&self) -> PathBuf {
156 self.store_path.join(&self.path)
157 }
158
159 fn sync_prepare(&self) -> Result<()> {
161 debug!("Preparing password store sync");
162
163 let store = Store::open(self.store_path.to_string_lossy().as_ref()).map_err(|e| {
164 debug!("Failed to open store for sync: {:?}", e);
165 Error::Storage(format!("Failed to open store for sync: {:?}", e))
166 })?;
167
168 let sync = store.sync();
169
170 match sync.prepare() {
171 Ok(()) => {
172 debug!("Successfully prepared store sync (pulled if remote configured)");
173 Ok(())
174 }
175 Err(e) => {
176 warn!("Failed to prepare store sync: {:?}", e);
177 Ok(())
178 }
179 }
180 }
181
182 fn sync_finalize(&self, message: &str) -> Result<()> {
184 debug!("Finalizing password store sync: {}", message);
185
186 let store = Store::open(self.store_path.to_string_lossy().as_ref()).map_err(|e| {
187 debug!("Failed to open store for sync: {:?}", e);
188 Error::Storage(format!("Failed to open store for sync: {:?}", e))
189 })?;
190
191 let sync = store.sync();
192
193 match sync.finalize(message) {
194 Ok(()) => {
195 debug!(
196 "Successfully finalized store sync (committed and pushed if remote configured)"
197 );
198 Ok(())
199 }
200 Err(e) => {
201 debug!("Failed to finalize store sync: {:?}", e);
202 warn!("Failed to finalize store sync: {:?}", e);
204 Ok(())
205 }
206 }
207 }
208
209 fn create_crypto_context(&self) -> Result<prs_lib::crypto::Context> {
211 let proto = match self.gpg_backend {
212 GpgBackend::Gpgme | GpgBackend::GnupgBin => prs_lib::crypto::Proto::Gpg,
213 };
214
215 let config = prs_lib::crypto::Config::from(proto);
216 debug!("Creating crypto context with protocol: {:?}", proto);
217
218 prs_lib::crypto::context(&config).map_err(|e| {
219 debug!("Failed to create crypto context: {:?}", e);
220 Error::Storage(format!("Failed to create crypto context: {:?}", e))
221 })
222 }
223
224 fn read_credential_from_path(&mut self, path: &Path) -> Result<soft_fido2::Credential> {
227 if let Some(cached) = self.cache.get(path) {
228 if Instant::now() < cached.expires_at {
229 debug!("Cache HIT for path: {:?}", path);
230 return Ok(cached.credential.clone());
231 } else {
232 debug!("Cache entry expired for path: {:?}", path);
233 }
234 }
235
236 debug!(
237 "Cache MISS - reading and decrypting credential from path: {:?}",
238 path
239 );
240
241 self.cache.evict_expired();
243
244 self.cache.evict_oldest_if_full();
246
247 let encrypted_data = std::fs::read(path).map_err(|e| {
249 debug!("Failed to read encrypted file: {}", e);
250 Error::Storage(format!("Failed to read file: {}", e))
251 })?;
252
253 let mut context = self.create_crypto_context()?;
255
256 let ciphertext = Ciphertext::from(encrypted_data);
258 let plaintext = context.decrypt(ciphertext).map_err(|e| {
259 error!("Failed to decrypt credential: {:?}", e);
260 Error::Storage(format!("Failed to decrypt credential: {:?}", e))
261 })?;
262
263 debug!("Successfully decrypted credential");
264
265 let credential: soft_fido2::Credential = Credential::from_bytes(plaintext.unsecure_ref())
267 .map(|cred| cred.to_soft_fido2())
268 .map_err(|e| {
269 error!("Failed to parse credential from {:?}: {:?}", path, e);
270 Error::Storage(format!("Failed to parse credential: {:?}", e))
271 })?;
272
273 self.cache.insert(path.to_path_buf(), credential.clone());
275
276 Ok(credential)
277 }
278
279 fn read_credential_by_id(&mut self, id: &[u8]) -> Result<soft_fido2::Credential> {
281 let path_info = self.indexes.id.get(id).ok_or_else(|| {
282 debug!("Credential not found in index");
283 Error::Storage("Credential not found".to_string())
284 })?;
285
286 let path = path_info.to_path(&self.get_fido2_path());
287 self.read_credential_from_path(&path)
288 }
289
290 fn find_nearest_gpg_id(&self, target: &Path) -> Result<(PathBuf, String)> {
293 gpg_id::find_nearest_gpg_id(&self.store_path, target)
294 }
295
296 fn resolve_recipients_for_target(&self, target: &Path) -> Result<prs_lib::Recipients> {
298 gpg_id::resolve_recipients_for_target(&self.store_path, target)
299 }
300
301 #[allow(dead_code)]
303 fn parse_gpg_id_content(
304 &self,
305 content: &str,
306 gpg_id_path: &Path,
307 ) -> Result<prs_lib::Recipients> {
308 gpg_id::parse_gpg_id_content(content, gpg_id_path)
309 }
310
311 fn write_credential_bytes(
313 &mut self,
314 cred: &soft_fido2::Credential,
315 cred_bytes: &[u8],
316 ) -> Result<()> {
317 self.cache.evict_expired();
318
319 let rp_id = validate_rp_id_for_storage(cred.rp.id.as_str())
320 .map_err(|e| Error::Storage(format!("Invalid RP ID: {}", e)))?;
321
322 let path = get_credential_path(&self.get_fido2_path(), &rp_id, &cred.id, "gpg");
323 debug!("Writing credential to: {:?}", path);
324
325 if let Some(parent) = path.parent() {
327 create_secure_dir_all(parent).map_err(|e| {
328 debug!("Failed to create directory: {}", e);
329 Error::Storage(format!("Failed to create directory: {}", e))
330 })?;
331 }
332
333 let recipients = self.resolve_recipients_for_target(&path)?;
335
336 let mut context = self.create_crypto_context()?;
338
339 let plaintext = Plaintext::from(cred_bytes.to_vec());
341
342 context
343 .encrypt_file(&recipients, plaintext, &path)
344 .map_err(|e| {
345 debug!("Failed to encrypt credential: {:?}", e);
346 Error::Storage(format!("Failed to encrypt credential: {:?}", e))
347 })?;
348
349 debug!("Successfully wrote and encrypted credential");
350
351 self.cache.remove(&path);
353
354 let path_info = CredentialPathInfo::new(rp_id, cred.id.to_vec(), "gpg".to_string());
356 update_indexes_on_write(&mut self.indexes, path_info);
357
358 let relative_path = path
360 .strip_prefix(&self.store_path)
361 .unwrap_or(&path)
362 .display();
363 let commit_message = format!("Add generated password for {}.", relative_path);
364 self.sync_finalize(&commit_message)?;
365
366 Ok(())
367 }
368
369 fn delete_credential(&mut self, id: &[u8]) -> Result<()> {
371 self.cache.evict_expired();
372
373 debug!("Deleting credential with ID: {}", bytes_to_hex(id));
374
375 let path_info = self
376 .indexes
377 .id
378 .get(id)
379 .ok_or_else(|| {
380 debug!("Credential not found in index");
381 Error::Storage("Credential not found".to_string())
382 })?
383 .clone();
384
385 let path = path_info.to_path(&self.get_fido2_path());
387
388 std::fs::remove_file(&path).map_err(|e| {
390 debug!("Failed to delete file: {}", e);
391 Error::Storage(format!("Failed to delete file: {}", e))
392 })?;
393
394 self.cache.remove(&path);
396
397 update_indexes_on_delete(&mut self.indexes, id);
399
400 debug!("Successfully deleted credential");
401
402 let relative_path = path
404 .strip_prefix(&self.store_path)
405 .unwrap_or(&path)
406 .display();
407 let commit_message = format!("Remove {} from store.", relative_path);
408 self.sync_finalize(&commit_message)?;
409
410 Ok(())
411 }
412
413 fn find_next(&mut self) -> Result<soft_fido2::Credential> {
416 debug!(
417 "Finding next credential (index: {}/{})",
418 self.iteration_index,
419 self.iteration_entries.len()
420 );
421
422 if self.iteration_index >= self.iteration_entries.len() {
423 debug!("No more credentials matching filter");
424 return Err(Error::Storage("No more credentials".to_string()));
425 }
426
427 let path = self.iteration_entries[self.iteration_index].clone();
428 self.iteration_index += 1;
429
430 self.read_credential_from_path(&path)
431 }
432
433 #[allow(dead_code)]
440 pub fn audit_passkey_recipients(&self) -> Result<Vec<AuditEntry>> {
441 let fido2_path = self.get_fido2_path();
442 let indexes = load_credential_paths(&fido2_path, "gpg")
443 .map_err(|e| Error::Storage(format!("Failed to scan credentials: {}", e)))?;
444
445 let mut entries = Vec::new();
446
447 for path_info in indexes.id.values() {
448 let path = path_info.to_path(&fido2_path);
449 if !path.exists() {
450 continue;
451 }
452
453 let actual = match extract_gpg_key_ids(&path) {
454 Ok(ids) => ids,
455 Err(e) => {
456 entries.push(AuditEntry {
457 path,
458 expected_recipients: vec![],
459 actual_recipients: vec![],
460 match_result: RecipientMatch::InspectionError(e.to_string()),
461 });
462 continue;
463 }
464 };
465
466 let expected = match self.find_nearest_gpg_id(&path) {
467 Ok((_, content)) => gpg_id::parse_raw_key_ids(&content),
468 Err(e) => {
469 entries.push(AuditEntry {
470 path,
471 expected_recipients: vec![],
472 actual_recipients: actual.clone(),
473 match_result: RecipientMatch::ResolutionError(e.to_string()),
474 });
475 continue;
476 }
477 };
478
479 let match_result = if actual == expected {
480 RecipientMatch::Match
481 } else {
482 RecipientMatch::Mismatch
483 };
484
485 entries.push(AuditEntry {
486 path,
487 expected_recipients: expected,
488 actual_recipients: actual,
489 match_result,
490 });
491 }
492
493 Ok(entries)
494 }
495
496 #[allow(dead_code)]
505 pub fn reencrypt_passkey_file(&mut self, path: &Path) -> Result<()> {
506 if !path.exists() {
507 return Err(Error::Storage(format!(
508 "File does not exist: {}",
509 path.display()
510 )));
511 }
512
513 let encrypted_data = std::fs::read(path).map_err(|e| {
514 Error::Storage(format!("Failed to read file {}: {}", path.display(), e))
515 })?;
516
517 let mut context = self.create_crypto_context()?;
518
519 let ciphertext = Ciphertext::from(encrypted_data);
520 let plaintext = context.decrypt(ciphertext).map_err(|e| {
521 Error::Storage(format!(
522 "Failed to decrypt {} for re-encryption: {:?}",
523 path.display(),
524 e
525 ))
526 })?;
527
528 let recipients = self.resolve_recipients_for_target(path)?;
529
530 let parent = path
531 .parent()
532 .ok_or_else(|| Error::Storage(format!("No parent directory for {}", path.display())))?;
533
534 let tmp_name = format!(
535 ".reencrypt.{}.{}",
536 std::process::id(),
537 path.file_name()
538 .and_then(|s| s.to_str())
539 .unwrap_or("credential.gpg")
540 );
541 let tmp_path = parent.join(&tmp_name);
542
543 context
544 .encrypt_file(&recipients, plaintext, &tmp_path)
545 .map_err(|e| {
546 Error::Storage(format!(
547 "Failed to re-encrypt credential {}: {:?}",
548 path.display(),
549 e
550 ))
551 })?;
552
553 std::fs::rename(&tmp_path, path).map_err(|e| {
554 Error::Storage(format!(
555 "Failed to replace {} with re-encrypted version: {}",
556 path.display(),
557 e
558 ))
559 })?;
560
561 let relative_path = path
562 .strip_prefix(&self.store_path)
563 .unwrap_or(path)
564 .display();
565 let message = format!(
566 "Re-encrypt {} with current .gpg-id recipient policy.",
567 relative_path
568 );
569 self.sync_finalize(&message)?;
570
571 info!("Re-encrypted {}", path.display());
572 Ok(())
573 }
574}
575
576#[allow(dead_code)]
579fn extract_gpg_key_ids(path: &Path) -> Result<Vec<String>> {
580 let output = std::process::Command::new("gpg")
581 .args(["--batch", "--no-tty", "--list-packets", "--verbose"])
582 .arg(path)
583 .output()
584 .map_err(|e| {
585 Error::Storage(format!(
586 "Failed to run gpg --list-packets on {}: {}",
587 path.display(),
588 e
589 ))
590 })?;
591
592 if !output.status.success() {
593 let stderr = String::from_utf8_lossy(&output.stderr);
594 return Err(Error::Storage(format!(
595 "gpg --list-packets failed for {}: {}",
596 path.display(),
597 stderr.trim()
598 )));
599 }
600
601 let stdout = String::from_utf8_lossy(&output.stdout);
602 let mut key_ids: Vec<String> = Vec::new();
603
604 for line in stdout.lines() {
605 if line.contains("pubkey enc packet")
606 && let Some(after_keyid) = line.split("keyid ").nth(1)
607 {
608 let kid = after_keyid
609 .split_whitespace()
610 .next()
611 .unwrap_or("")
612 .trim()
613 .to_string();
614 if !kid.is_empty() && !key_ids.contains(&kid) {
615 key_ids.push(kid);
616 }
617 }
618 }
619
620 Ok(key_ids)
621}
622
623#[allow(dead_code)]
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub enum RecipientMatch {
627 Match,
629 Mismatch,
631 ResolutionError(String),
633 InspectionError(String),
635}
636
637#[allow(dead_code)]
639#[derive(Debug, Clone)]
640pub struct AuditEntry {
641 pub path: PathBuf,
643 pub expected_recipients: Vec<String>,
645 pub actual_recipients: Vec<String>,
647 pub match_result: RecipientMatch,
649}
650
651impl CredentialStorage for PassStorageAdapter {
652 fn read_first(
653 &mut self,
654 filter: CredentialFilter,
655 ) -> soft_fido2::Result<soft_fido2::Credential> {
656 self.cache.evict_expired();
657
658 debug!("read_first called with filter: {:?}", filter);
659
660 self.iteration_entries = self.indexes.resolve_filter(&filter, &self.get_fido2_path());
661 self.iteration_index = 0;
662
663 debug!(
664 "Starting iteration with {} entries for filter: {:?}",
665 self.iteration_entries.len(),
666 filter
667 );
668
669 self.find_next().map_err(Into::into)
670 }
671
672 fn read_next(&mut self) -> soft_fido2::Result<soft_fido2::Credential> {
673 self.cache.evict_expired();
674
675 debug!("read_next called");
676 self.find_next().map_err(Into::into)
677 }
678
679 fn read(&mut self, id: &[u8]) -> soft_fido2::Result<soft_fido2::Credential> {
680 self.cache.evict_expired();
681
682 debug!("read called with id: {}", bytes_to_hex(id));
683
684 self.read_credential_by_id(id).map_err(Into::into)
686 }
687
688 fn write(&mut self, cred_ref: soft_fido2::CredentialRef) -> soft_fido2::Result<()> {
689 self.cache.evict_expired();
690
691 debug!("write called for RP: {}", cred_ref.rp_id);
692
693 let credential = cred_ref.to_owned();
694 let our_cred = Credential::from_soft_fido2(&credential);
696 let cred_bytes = Zeroizing::new(our_cred.to_bytes().map_err(|e| {
698 debug!("Failed to serialize credential: {:?}", e);
699 Error::Storage(format!("Failed to serialize credential: {:?}", e))
700 })?);
701 self.write_credential_bytes(&credential, &cred_bytes)
702 .map_err(Into::into)
703 }
704
705 fn delete(&mut self, id: &[u8]) -> soft_fido2::Result<()> {
706 self.cache.evict_expired();
707
708 debug!("delete called with id: {}", bytes_to_hex(id));
709 self.delete_credential(id).map_err(Into::into)
710 }
711
712 fn count_credentials(&self) -> usize {
713 let count = self.indexes.id.len();
714 debug!("count_credentials: {}", count);
715 count
716 }
717
718 fn disable_user_verification(&self) -> bool {
719 true
721 }
722
723 fn cleanup_expired_cache(&mut self) {
724 self.cache.evict_expired();
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 use std::fs;
733
734 fn write_gpg_id(dir: &Path, content: &str) {
735 fs::write(dir.join(".gpg-id"), content).unwrap();
736 }
737
738 fn create_adapter(store_root: &Path) -> PassStorageAdapter {
739 PassStorageAdapter {
740 store_path: store_root.to_path_buf(),
741 path: PathBuf::from("fido2"),
742 gpg_backend: GpgBackend::GnupgBin,
743 indexes: CredentialIndexes::default(),
744 cache: CredentialCache::new(),
745 iteration_index: 0,
746 iteration_entries: vec![],
747 }
748 }
749
750 #[test]
753 fn test_parse_single_key_id() {
754 let adapter = create_adapter(Path::new("/tmp/test"));
755 let dir = Path::new("/tmp/test");
756 let content = "ABCDEF0123456789ABCDEF0123456789ABCDEF01\n";
757 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
758 assert!(result.is_ok());
759 }
760
761 #[test]
762 fn test_parse_skips_comments_and_blanks() {
763 let adapter = create_adapter(Path::new("/tmp/test"));
764 let dir = Path::new("/tmp/test");
765 let content = "# comment\n\nABCDEF0123456789ABCDEF0123456789ABCDEF01\n \n";
766 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
767 assert!(result.is_ok());
768 }
769
770 #[test]
771 fn test_parse_rejects_short_key_id() {
772 let adapter = create_adapter(Path::new("/tmp/test"));
773 let dir = Path::new("/tmp/test");
774 let content = "DEADBEEF\n";
775 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
776 assert!(result.is_err(), "short 8-char key ID should be rejected");
777 let err = match result {
778 Err(e) => e.to_string(),
779 _ => unreachable!(),
780 };
781 assert!(
782 err.contains("8-character"),
783 "error should mention 8-char: {}",
784 err
785 );
786 }
787
788 #[test]
789 fn test_parse_strips_subkey_marker() {
790 let adapter = create_adapter(Path::new("/tmp/test"));
791 let dir = Path::new("/tmp/test");
792 let content = "ABCDEF0123456789ABCDEF0123456789ABCDEF01!\n";
793 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
794 assert!(
795 result.is_ok(),
796 "key ID with ! subkey marker should be accepted"
797 );
798 }
799
800 #[test]
801 fn test_parse_multiple_recipients() {
802 let adapter = create_adapter(Path::new("/tmp/test"));
803 let dir = Path::new("/tmp/test");
804 let content =
805 "ABCDEF0123456789ABCDEF0123456789ABCDEF01\n1234567890ABCDEF1234567890ABCDEF12345678\n";
806 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
807 assert!(result.is_ok());
808 }
809
810 #[test]
811 fn test_parse_empty_content_fails() {
812 let adapter = create_adapter(Path::new("/tmp/test"));
813 let dir = Path::new("/tmp/test");
814 let result = adapter.parse_gpg_id_content("", &dir.join(".gpg-id"));
815 assert!(result.is_err());
816 }
817
818 #[test]
819 fn test_parse_only_comments_fails() {
820 let adapter = create_adapter(Path::new("/tmp/test"));
821 let dir = Path::new("/tmp/test");
822 let result = adapter.parse_gpg_id_content("# only a comment\n", &dir.join(".gpg-id"));
823 assert!(result.is_err());
824 }
825
826 #[test]
827 fn test_parse_non_hex_chars_skipped() {
828 let adapter = create_adapter(Path::new("/tmp/test"));
829 let dir = Path::new("/tmp/test");
830 let content = "NOTHEX!!\nABCDEF0123456789ABCDEF0123456789ABCDEF01\n";
831 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
832 assert!(
833 result.is_ok(),
834 "non-hex lines should be skipped, valid keys should remain"
835 );
836 }
837
838 #[test]
839 fn test_parse_0x_prefix_stripped() {
840 let adapter = create_adapter(Path::new("/tmp/test"));
841 let dir = Path::new("/tmp/test");
842 let content = "0xABCDEF0123456789ABCDEF0123456789ABCDEF01\n";
843 let result = adapter.parse_gpg_id_content(content, &dir.join(".gpg-id"));
844 assert!(result.is_ok(), "0x-prefixed key ID should be accepted");
845 }
846
847 #[test]
850 fn test_resolve_root_gpg_id() {
851 let dir = tempfile::tempdir().unwrap();
852 let root = dir.path().to_path_buf();
853 let target = root.join("fido2/example.com/cred.gpg");
854 fs::create_dir_all(target.parent().unwrap()).unwrap();
855
856 write_gpg_id(&root, "ABCDEF0123456789ABCDEF0123456789ABCDEF01\n");
857
858 let adapter = create_adapter(&root);
859 let result = adapter.resolve_recipients_for_target(&target);
860 assert!(
861 result.is_ok(),
862 "should find root .gpg-id: {:?}",
863 result.err()
864 );
865 }
866
867 #[test]
868 fn test_resolve_hierarchical_fido2_overrides_root() {
869 let dir = tempfile::tempdir().unwrap();
870 let root = dir.path().to_path_buf();
871 let fido2_dir = root.join("fido2");
872 let target = fido2_dir.join("example.com/cred.gpg");
873 fs::create_dir_all(target.parent().unwrap()).unwrap();
874
875 write_gpg_id(&root, "0000000000000000000000000000000000000001\n");
876 write_gpg_id(&fido2_dir, "0000000000000000000000000000000000000002\n");
877
878 let adapter = create_adapter(&root);
879 let result = adapter.resolve_recipients_for_target(&target);
880 assert!(
881 result.is_ok(),
882 "should find fido2/.gpg-id: {:?}",
883 result.err()
884 );
885 }
886
887 #[test]
888 fn test_resolve_hierarchical_rp_dir_overrides_ancestors() {
889 let dir = tempfile::tempdir().unwrap();
890 let root = dir.path().to_path_buf();
891 let fido2_dir = root.join("fido2");
892 let rp_dir = fido2_dir.join("example.com");
893 let target = rp_dir.join("cred.gpg");
894 fs::create_dir_all(target.parent().unwrap()).unwrap();
895
896 write_gpg_id(&root, "0000000000000000000000000000000000000001\n");
897 write_gpg_id(&fido2_dir, "0000000000000000000000000000000000000002\n");
898 write_gpg_id(&rp_dir, "0000000000000000000000000000000000000003\n");
899
900 let adapter = create_adapter(&root);
901 let result = adapter.resolve_recipients_for_target(&target);
902 assert!(
903 result.is_ok(),
904 "should find example.com/.gpg-id: {:?}",
905 result.err()
906 );
907 }
908
909 #[test]
910 fn test_resolve_target_outside_store_fails() {
911 let dir = tempfile::tempdir().unwrap();
912 let root = dir.path().join("store");
913 fs::create_dir_all(&root).unwrap();
914
915 let outside = dir.path().join("outside/cred.gpg");
916 let adapter = create_adapter(&root);
917 let result = adapter.resolve_recipients_for_target(&outside);
918 assert!(result.is_err(), "target outside store should fail");
919 let err = match result {
920 Err(e) => e.to_string(),
921 _ => unreachable!(),
922 };
923 assert!(err.contains("not within store root"));
924 }
925
926 #[test]
927 fn test_resolve_missing_gpg_id_fails() {
928 let dir = tempfile::tempdir().unwrap();
929 let root = dir.path().to_path_buf();
930 let target = root.join("fido2/example.com/cred.gpg");
931 fs::create_dir_all(target.parent().unwrap()).unwrap();
932
933 let adapter = create_adapter(&root);
934 let result = adapter.resolve_recipients_for_target(&target);
935 assert!(result.is_err(), "missing .gpg-id should fail");
936 }
937
938 #[test]
939 fn test_resolve_empty_gpg_id_fails() {
940 let dir = tempfile::tempdir().unwrap();
941 let root = dir.path().to_path_buf();
942 let target = root.join("fido2/example.com/cred.gpg");
943 fs::create_dir_all(target.parent().unwrap()).unwrap();
944
945 write_gpg_id(&root, "# only a comment\n");
946
947 let adapter = create_adapter(&root);
948 let result = adapter.resolve_recipients_for_target(&target);
949 assert!(result.is_err(), "empty .gpg-id (only comments) should fail");
950 }
951
952 #[test]
953 fn test_resolve_short_key_id_in_root_fails() {
954 let dir = tempfile::tempdir().unwrap();
955 let root = dir.path().to_path_buf();
956 let target = root.join("fido2/example.com/cred.gpg");
957 fs::create_dir_all(target.parent().unwrap()).unwrap();
958
959 write_gpg_id(&root, "DEADBEEF\n");
960
961 let adapter = create_adapter(&root);
962 let result = adapter.resolve_recipients_for_target(&target);
963 assert!(
964 result.is_err(),
965 "short 8-char key ID in .gpg-id should fail"
966 );
967 }
968
969 #[test]
970 fn test_resolve_with_subkey_marker() {
971 let dir = tempfile::tempdir().unwrap();
972 let root = dir.path().to_path_buf();
973 let target = root.join("fido2/example.com/cred.gpg");
974 fs::create_dir_all(target.parent().unwrap()).unwrap();
975
976 write_gpg_id(&root, "ABCDEF0123456789ABCDEF0123456789ABCDEF01!\n");
977
978 let adapter = create_adapter(&root);
979 let result = adapter.resolve_recipients_for_target(&target);
980 assert!(result.is_ok(), "key IDs with ! marker should be accepted");
981 }
982
983 #[test]
986 fn test_find_nearest_gpg_id_root_only() {
987 let dir = tempfile::tempdir().unwrap();
988 let root = dir.path().to_path_buf();
989 let target = root.join("fido2/example.com/cred.gpg");
990 fs::create_dir_all(target.parent().unwrap()).unwrap();
991
992 write_gpg_id(&root, "ABCDEF0123456789ABCDEF0123456789ABCDEF01\n");
993
994 let adapter = create_adapter(&root);
995 let (found_path, content) = adapter.find_nearest_gpg_id(&target).unwrap();
996 assert_eq!(found_path, root.join(".gpg-id"));
997 assert!(content.contains("ABCDEF"));
998 }
999
1000 #[test]
1001 fn test_find_nearest_gpg_id_prefers_closest() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let root = dir.path().to_path_buf();
1004 let fido2_dir = root.join("fido2");
1005 let rp_dir = fido2_dir.join("example.com");
1006 let target = rp_dir.join("cred.gpg");
1007 fs::create_dir_all(target.parent().unwrap()).unwrap();
1008
1009 write_gpg_id(&root, "ROOT00000000000000000000000000000000001\n");
1010 write_gpg_id(&rp_dir, "RP000000000000000000000000000000000003\n");
1011
1012 let adapter = create_adapter(&root);
1013 let (found_path, _) = adapter.find_nearest_gpg_id(&target).unwrap();
1014 assert_eq!(found_path, rp_dir.join(".gpg-id"));
1015 }
1016
1017 #[test]
1018 fn test_find_nearest_gpg_id_outside_store_fails() {
1019 let dir = tempfile::tempdir().unwrap();
1020 let root = dir.path().join("store");
1021 fs::create_dir_all(&root).unwrap();
1022
1023 let outside = dir.path().join("outside/cred.gpg");
1024 let adapter = create_adapter(&root);
1025 let result = adapter.find_nearest_gpg_id(&outside);
1026 assert!(result.is_err());
1027 }
1028}