Skip to main content

passless_rs/storage/pass/
mod.rs

1//! Pass (password-store) storage adapter
2
3pub 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
26/// Pass (password-store) storage adapter
27///
28/// Stores credentials as GPG-encrypted files in a password store directory.
29/// Uses prs-lib for password store operations.
30pub 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/// GPG backend selection for encryption/decryption
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum GpgBackend {
43    /// Use GPGME library (if available)
44    Gpgme,
45    /// Use GnuPG binary
46    #[default]
47    GnupgBin,
48}
49
50impl std::str::FromStr for GpgBackend {
51    type Err = Error;
52
53    /// Parse from string
54    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(()) // Don't fail, just warn
90                    }
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        // Ensure the password store is initialized
122        // This will prompt the user via notifications if not initialized
123        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        // Pull latest changes from git remote if configured
145        adapter.sync_prepare()?;
146
147        // Load indexes by scanning directory structure (no decryption!)
148        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    /// Get the FIDO path within the password store
155    fn get_fido2_path(&self) -> PathBuf {
156        self.store_path.join(&self.path)
157    }
158
159    /// Prepare the store for changes (pulls from git remote if configured)
160    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    /// Finalize changes to the store (commits and pushes to git remote if configured)
183    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                // Don't fail the operation if sync fails, just log a warning
203                warn!("Failed to finalize store sync: {:?}", e);
204                Ok(())
205            }
206        }
207    }
208
209    /// Create a crypto context based on the configured backend
210    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    /// Read a credential from a specific file path
225    /// Uses time-limited cache to avoid redundant GPG decryption
226    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        // Evict expired entries before adding new one
242        self.cache.evict_expired();
243
244        // If cache is full, evict oldest entry
245        self.cache.evict_oldest_if_full();
246
247        // Read the encrypted GPG file
248        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        // Create crypto context
254        let mut context = self.create_crypto_context()?;
255
256        // Decrypt the data
257        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        // Parse credential from decrypted bytes
266        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        // Cache the decrypted credential with automatic TTL
274        self.cache.insert(path.to_path_buf(), credential.clone());
275
276        Ok(credential)
277    }
278
279    /// Read a credential by its ID
280    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    /// Find the nearest `.gpg-id` file by walking from `target`'s parent
291    /// directory up to `store_root`. Returns the path and raw content.
292    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    /// Resolve GPG recipients for a target file using hierarchical .gpg-id lookup.
297    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    /// Parse GPG key IDs from .gpg-id file content.
302    #[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    /// Write a credential to the store
312    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        // Ensure parent directory exists with secure permissions
326        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        // Resolve recipients by walking up from target to find the nearest .gpg-id
334        let recipients = self.resolve_recipients_for_target(&path)?;
335
336        // Create crypto context
337        let mut context = self.create_crypto_context()?;
338
339        // Encrypt and write the credential data directly to file
340        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        // Invalidate cache entry for this credential to ensure fresh reads
352        self.cache.remove(&path);
353
354        // Update all indexes using shared function
355        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        // Commit and push changes to git remote if configured
359        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    /// Delete a credential from the store
370    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        // Convert to actual path
386        let path = path_info.to_path(&self.get_fido2_path());
387
388        // Delete the file
389        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        // Remove from cache
395        self.cache.remove(&path);
396
397        // Remove from all indexes using shared function
398        update_indexes_on_delete(&mut self.indexes, id);
399
400        debug!("Successfully deleted credential");
401
402        // Commit and push changes to git remote if configured
403        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    /// Find the next credential matching the current filter
414    /// Uses indexes for efficient lookup
415    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    // ── Audit / re-encrypt ──────────────────────────────────────────────────
434
435    /// Scan all passkey files and report those whose OpenPGP packet recipients
436    /// differ from the effective `.gpg-id` policy.
437    ///
438    /// Requires the `gpg` binary for packet inspection.
439    #[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    /// Re-encrypt a passkey file so its OpenPGP recipients match the current
497    /// `.gpg-id` policy.
498    ///
499    /// 1. Decrypts the file using an available secret key.
500    /// 2. Resolves the effective recipients via `resolve_recipients_for_target`.
501    /// 3. Re-encrypts to a temporary file.
502    /// 4. Atomically renames the temporary over the original.
503    /// 5. Commits the change via the password-store Git integration.
504    #[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/// Extract GPG public-key-encrypted session key IDs from an OpenPGP file by
577/// running `gpg --list-packets --verbose`.
578#[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/// Result of comparing expected vs actual recipients for a single credential file.
624#[allow(dead_code)]
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub enum RecipientMatch {
627    /// The credential's OpenPGP recipients match the .gpg-id policy.
628    Match,
629    /// The credential's recipients differ from the .gpg-id policy.
630    Mismatch,
631    /// The expected recipients could not be resolved.
632    ResolutionError(String),
633    /// The actual recipients could not be inspected.
634    InspectionError(String),
635}
636
637/// An audit entry describing the recipient match status for one credential file.
638#[allow(dead_code)]
639#[derive(Debug, Clone)]
640pub struct AuditEntry {
641    /// Path to the credential file.
642    pub path: PathBuf,
643    /// Expected recipient key IDs (16-char uppercase hex).
644    pub expected_recipients: Vec<String>,
645    /// Actual recipient key IDs extracted from the OpenPGP packets.
646    pub actual_recipients: Vec<String>,
647    /// Whether the expected and actual recipients match.
648    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        // Load and return credential directly (no re-serialization)
685        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        // Convert to our format for controlled serialization
695        let our_cred = Credential::from_soft_fido2(&credential);
696        // Use Zeroizing to ensure credential bytes are cleared from memory after use
697        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        // Pass backend doesn't support user verification
720        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    // ── parse_gpg_id_content ─────────────────────────────────────────────
751
752    #[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    // ── resolve_recipients_for_target ─────────────────────────────────────
848
849    #[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    // ── find_nearest_gpg_id ───────────────────────────────────────────────
984
985    #[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}