Skip to main content

passless_core/
config.rs

1//! Application configuration using clap-serde-derive
2//!
3//! This module provides a unified configuration approach where settings can come from:
4//! 1. CLI arguments (highest priority)
5//! 2. Configuration file (medium priority)
6//! 3. Default values (lowest priority)
7
8use std::fs::{self, File};
9use std::io::BufReader;
10use std::path::{Path, PathBuf};
11
12use clap::{ArgAction, Parser, Subcommand};
13use clap_serde_derive::ClapSerde;
14use libc::{PR_SET_DUMPABLE, prctl};
15use libc::{mlock, munlock};
16use log::debug;
17use nix::sys::resource::{Resource, setrlimit};
18use passless_config_doc::ConfigDoc;
19use serde::{Deserialize, Serialize};
20
21/// Compute default local storage path
22pub fn local_path() -> String {
23    dirs::data_dir()
24        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
25        .join("passless/local")
26        .to_string_lossy()
27        .into_owned()
28}
29
30/// Local backend configuration
31#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
32#[group(id = "local-backend-config")]
33pub struct LocalBackendConfig {
34    /// Path to local storage directory
35    #[arg(
36        long = "local-path",
37        env = "PASSLESS_LOCAL_PATH",
38        id = "local-path",
39        value_name = "PATH"
40    )]
41    #[serde(default)]
42    #[default(local_path())]
43    pub path: String,
44}
45
46/// Compute default password-store path
47pub fn pass_store_path() -> String {
48    dirs::home_dir()
49        .expect("Could not determine home directory: $HOME")
50        .join(".password-store")
51        .to_string_lossy()
52        .into_owned()
53}
54/// Pass (password-store) backend configuration
55#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
56#[group(id = "pass-backend-config")]
57pub struct PassBackendConfig {
58    /// Path to password store directory
59    #[arg(
60        long = "pass-store-path",
61        env = "PASSLESS_PASS_STORE_PATH",
62        id = "pass-store-path",
63        value_name = "PATH"
64    )]
65    #[serde(default)]
66    #[default(pass_store_path())]
67    pub store_path: String,
68
69    /// Relative path within password store for FIDO2 entries
70    #[arg(
71        long = "pass-path",
72        env = "PASSLESS_PASS_PATH",
73        id = "pass-path",
74        value_name = "PATH"
75    )]
76    #[serde(default)]
77    #[default("fido2".to_string())]
78    pub path: String,
79
80    /// GPG backend: "gpgme" or "gnupg-bin"
81    #[arg(
82        long = "pass-gpg-backend",
83        env = "PASSLESS_PASS_GPG_BACKEND",
84        value_name = "BACKEND"
85    )]
86    #[serde(default)]
87    #[default("gnupg-bin".to_string())]
88    pub gpg_backend: String,
89}
90
91/// Compute default TPM storage path
92pub fn tpm_path() -> String {
93    dirs::data_dir()
94        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
95        .join("passless/tpm")
96        .to_string_lossy()
97        .into_owned()
98}
99
100/// TPM backend configuration
101#[cfg(feature = "tpm")]
102#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
103#[group(id = "tpm-backend-config")]
104pub struct TpmBackendConfig {
105    /// Path to TPM storage directory
106    #[arg(
107        long = "tpm-path",
108        env = "PASSLESS_TPM_PATH",
109        id = "tpm-path",
110        value_name = "PATH"
111    )]
112    #[serde(default)]
113    #[default(tpm_path())]
114    pub path: String,
115
116    /// TPM TCTI (TPM Command Transmission Interface) configuration
117    #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
118    #[serde(default)]
119    #[default("device:/dev/tpmrm0".to_string())]
120    pub tcti: String,
121}
122
123/// Security configuration
124#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
125#[group(id = "security")]
126pub struct SecurityConfig {
127    /// Check if mlock is available to prevent credentials from being swapped to disk
128    #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
129    #[serde(default)]
130    #[default(true)]
131    pub check_mlock: bool,
132
133    /// Disable core dumps to prevent credential leakage
134    #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
135    #[serde(default)]
136    #[default(true)]
137    pub disable_core_dumps: bool,
138
139    /// Enable constant signature counter to help RPs detect cloned authenticators
140    #[arg(
141        long = "constant-signature-counter",
142        env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
143        action = ArgAction::Set,
144        require_equals = true,
145        num_args = 0..=1,
146        default_missing_value = "true"
147    )]
148    #[serde(default)]
149    pub constant_signature_counter: bool,
150
151    /// Always require user verification for all operations
152    /// - When PIN is set + pin.enforcement="required": requires PIN
153    /// - When PIN is set + pin.enforcement="optional": depends on context
154    /// - When PIN is set + pin.enforcement="never": uses notification fallback
155    /// - When PIN not set: uses notification
156    #[arg(
157        long = "always-uv",
158        env = "PASSLESS_ALWAYS_UV",
159        action = ArgAction::Set,
160        require_equals = true,
161        num_args = 0..=1,
162        default_value = "true",
163        default_missing_value = "true"
164    )]
165    #[serde(default)]
166    #[default(true)]
167    pub always_uv: bool,
168
169    /// Show user verification notification during registration
170    #[arg(
171        long = "user-verification-registration",
172        env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
173    )]
174    #[serde(default)]
175    #[default(true)]
176    pub user_verification_registration: bool,
177
178    /// Show user verification notification during authentication
179    #[arg(
180        long = "user-verification-authentication",
181        env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
182    )]
183    #[serde(default)]
184    #[default(true)]
185    pub user_verification_authentication: bool,
186
187    /// Notification timeout in seconds (0 = no timeout)
188    #[arg(
189        long = "notification-timeout",
190        env = "PASSLESS_NOTIFICATION_TIMEOUT",
191        value_name = "SECONDS"
192    )]
193    #[serde(default)]
194    #[default(30)]
195    pub notification_timeout: u32,
196}
197
198/// PIN enforcement policy
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
200#[serde(rename_all = "lowercase")]
201pub enum PinEnforcement {
202    /// Never require PIN, always use notification fallback (backward compatible)
203    Never,
204    /// Use PIN only when always_uv=true or client requests UV
205    #[default]
206    Optional,
207    /// Always require PIN when set (most secure)
208    Required,
209}
210
211impl std::str::FromStr for PinEnforcement {
212    type Err = String;
213
214    fn from_str(s: &str) -> Result<Self, Self::Err> {
215        match s.to_lowercase().as_str() {
216            "never" => Ok(PinEnforcement::Never),
217            "optional" => Ok(PinEnforcement::Optional),
218            "required" => Ok(PinEnforcement::Required),
219            _ => Err(format!(
220                "Invalid PIN enforcement '{}'. Must be: never, optional, or required",
221                s
222            )),
223        }
224    }
225}
226
227impl std::fmt::Display for PinEnforcement {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            PinEnforcement::Never => write!(f, "never"),
231            PinEnforcement::Optional => write!(f, "optional"),
232            PinEnforcement::Required => write!(f, "required"),
233        }
234    }
235}
236
237/// PIN configuration
238#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
239#[group(id = "pin")]
240pub struct PinConfig {
241    /// PIN enforcement policy when PIN is set:
242    /// - "never": Always use notification fallback (backward compatible, convenience)
243    /// - "optional": Use PIN only when always_uv=true or client requests UV
244    /// - "required": Always require PIN when set (most secure)
245    #[arg(
246        long = "pin-enforcement",
247        env = "PASSLESS_PIN_ENFORCEMENT",
248        value_name = "POLICY"
249    )]
250    #[serde(default)]
251    #[default(PinEnforcement::Optional)]
252    pub enforcement: PinEnforcement,
253
254    /// Minimum PIN length in characters (CTAP spec: 4-63)
255    #[arg(
256        long = "pin-min-length",
257        env = "PASSLESS_PIN_MIN_LENGTH",
258        value_name = "LENGTH"
259    )]
260    #[serde(default)]
261    #[default(4)]
262    pub min_length: u8,
263
264    /// Maximum PIN retry attempts before lockout (CTAP spec: 8)
265    #[arg(
266        long = "pin-max-retries",
267        env = "PASSLESS_PIN_MAX_RETRIES",
268        value_name = "RETRIES"
269    )]
270    #[serde(default)]
271    #[default(8)]
272    pub max_retries: u8,
273
274    /// Maximum user verification retry attempts before UV is blocked (default: 8)
275    ///
276    /// This controls how many consecutive UV failures are allowed before UV is blocked.
277    /// Use `passless client pin uv-reset` to restore the retry counter after authentication.
278    /// Higher values improve usability but may reduce security against brute-force attacks.
279    #[arg(
280        long = "pin-max-uv-retries",
281        env = "PASSLESS_PIN_MAX_UV_RETRIES",
282        value_name = "RETRIES"
283    )]
284    #[serde(default)]
285    #[default(8)]
286    pub max_uv_retries: u8,
287
288    /// Auto-lock timeout in seconds after max failed attempts (0 = disabled)
289    /// After lockout, authenticator must be reset to use PIN again
290    #[arg(
291        long = "pin-auto-lock-timeout",
292        env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
293        value_name = "SECONDS"
294    )]
295    #[serde(default)]
296    #[default(0)]
297    pub auto_lock_timeout: u32,
298}
299
300impl PinConfig {
301    /// Validate PIN configuration values
302    pub fn validate(&self) -> crate::error::Result<()> {
303        if self.min_length < 4 || self.min_length > 63 {
304            return Err(crate::error::Error::Config(format!(
305                "pin.min_length must be between 4 and 63, got {}",
306                self.min_length
307            )));
308        }
309        if self.max_retries == 0 {
310            return Err(crate::error::Error::Config(
311                "pin.max_retries must be greater than 0".to_string(),
312            ));
313        }
314        if self.max_uv_retries == 0 {
315            return Err(crate::error::Error::Config(
316                "pin.max_uv_retries must be greater than 0".to_string(),
317            ));
318        }
319        Ok(())
320    }
321}
322
323impl SecurityConfig {
324    /// Apply security hardening measures
325    pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
326        if self.disable_core_dumps {
327            self.disable_core_dumps_impl()?;
328        }
329        if self.check_mlock {
330            self.probe_mlock_capability()?;
331        }
332        Ok(())
333    }
334
335    /// Disable core dumps to prevent credential leakage
336    fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
337        debug!("Disabling core dumps to prevent credential leakage");
338        setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
339        let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
340        if r != 0 {
341            log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
342        }
343        Ok(())
344    }
345
346    /// Probe mlock capability by testing with a small allocation
347    fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
348        debug!("Check mlock capability");
349
350        let test_size = 4096;
351        let test_buffer = vec![0u8; test_size];
352        let ptr = test_buffer.as_ptr() as *const libc::c_void;
353
354        let lock_result = unsafe { mlock(ptr, test_size) };
355
356        if lock_result == 0 {
357            unsafe { munlock(ptr, test_size) };
358            log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
359        } else {
360            log::warn!(
361                "mlock capability probe failed - memory locking may not be available.\n\
362                 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
363            );
364        }
365        Ok(())
366    }
367}
368
369/// Main application configuration
370/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
371#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
372pub struct AppConfig {
373    /// Storage backend type: pass, tpm (experimental), or local (for testing)
374    #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
375    #[serde(default)]
376    #[default("pass".to_string())]
377    pub backend_type: String,
378
379    /// Enable verbose logging
380    // workaround for allowing `-v` syntax instead of `-v=true`
381    #[arg(
382        short,
383        long,
384        env = "PASSLESS_VERBOSE",
385        action = ArgAction::Set,
386        require_equals = true,
387        num_args = 0..=1,
388        default_missing_value = "true"
389    )]
390    #[default(true)]
391    #[serde(default)]
392    pub verbose: bool,
393
394    /// Pass backend configuration
395    #[clap_serde]
396    #[serde(default)]
397    #[command(flatten)]
398    pub pass: PassBackendConfig,
399
400    /// TPM backend configuration
401    #[cfg(feature = "tpm")]
402    #[clap_serde]
403    #[serde(default)]
404    #[command(flatten)]
405    pub tpm: TpmBackendConfig,
406
407    /// Local backend configuration
408    #[clap_serde]
409    #[serde(default)]
410    #[command(flatten)]
411    pub local: LocalBackendConfig,
412
413    /// Security hardening configuration
414    #[clap_serde]
415    #[serde(default)]
416    #[command(flatten)]
417    pub security: SecurityConfig,
418
419    /// PIN configuration
420    #[clap_serde]
421    #[serde(default)]
422    #[command(flatten)]
423    pub pin: PinConfig,
424}
425
426/// Backend-specific configuration
427#[derive(Debug, Clone)]
428pub enum BackendConfig {
429    Local {
430        path: String,
431    },
432    Pass {
433        store_path: String,
434        path: String,
435        gpg_backend: String,
436    },
437    #[cfg(feature = "tpm")]
438    Tpm {
439        path: String,
440        tcti: String,
441    },
442}
443
444impl BackendConfig {
445    /// Canonicalize a path, resolving symlinks for existing parents.
446    ///
447    /// If the full path does not exist, canonicalize the longest existing
448    /// prefix and append the remaining components lexically.
449    pub fn canonicalize_path(path: &Path) -> PathBuf {
450        match fs::canonicalize(path) {
451            Ok(p) => p,
452            Err(_) => {
453                let mut current = path.to_path_buf();
454                let mut suffix = Vec::new();
455                loop {
456                    match fs::canonicalize(&current) {
457                        Ok(base) => {
458                            let mut result = base;
459                            for component in suffix.iter().rev() {
460                                result.push(component);
461                            }
462                            return result;
463                        }
464                        Err(_) => {
465                            if let Some(file_name) = current.file_name() {
466                                suffix.push(file_name.to_os_string());
467                                current = current
468                                    .parent()
469                                    .map(|p| p.to_path_buf())
470                                    .unwrap_or_default();
471                            } else {
472                                return path.to_path_buf();
473                            }
474                        }
475                    }
476                }
477            }
478        }
479    }
480
481    /// Return the canonical state path for this backend.
482    ///
483    /// This is used as the identity for the instance lock: two daemons with the
484    /// same canonical state path will contend for the same lock.
485    pub fn state_path(&self) -> PathBuf {
486        match self {
487            BackendConfig::Local { path } => Self::canonicalize_path(Path::new(path)),
488            BackendConfig::Pass {
489                store_path, path, ..
490            } => Self::canonicalize_path(&Path::new(store_path).join(path)),
491            #[cfg(feature = "tpm")]
492            BackendConfig::Tpm { path, .. } => Self::canonicalize_path(Path::new(path)),
493        }
494    }
495
496    /// Return a human-readable display string for the backend state path.
497    pub fn state_display(&self) -> String {
498        match self {
499            BackendConfig::Local { path } => path.clone(),
500            BackendConfig::Pass {
501                store_path, path, ..
502            } => {
503                format!("{}/{}", store_path, path)
504            }
505            #[cfg(feature = "tpm")]
506            BackendConfig::Tpm { path, .. } => path.clone(),
507        }
508    }
509}
510
511impl AppConfig {
512    /// Load configuration with precedence: CLI > config file > defaults
513    pub fn load(args: &mut Args) -> Self {
514        // Try to load config file
515        let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
516
517        let config_file_path = args
518            .config_path
519            .as_ref()
520            .or(default_config_path.as_ref())
521            .filter(|p| p.exists());
522
523        if let Some(path) = config_file_path
524            && let Ok(f) = File::open(path)
525        {
526            log::info!("Loading configuration from: {}", path.display());
527            let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
528            match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
529                Ok(file_config) => {
530                    // Deserialize into Opt, then convert with defaults and merge CLI args
531                    return AppConfig::from(file_config).merge(&mut args.config);
532                }
533                Err(e) => log::warn!("Failed to parse config file {}: {}", path.display(), e),
534            }
535        }
536
537        // No config file or parse failed - use CLI args + defaults
538        AppConfig::from(&mut args.config)
539    }
540
541    /// Get the backend configuration based on the backend_type
542    pub fn backend(&self) -> crate::error::Result<BackendConfig> {
543        match self.backend_type.as_str() {
544            "local" => Ok(BackendConfig::Local {
545                path: self.local.path.clone(),
546            }),
547            "pass" => Ok(BackendConfig::Pass {
548                store_path: self.pass.store_path.clone(),
549                path: self.pass.path.clone(),
550                gpg_backend: self.pass.gpg_backend.clone(),
551            }),
552            #[cfg(feature = "tpm")]
553            "tpm" => Ok(BackendConfig::Tpm {
554                path: self.tpm.path.clone(),
555                tcti: self.tpm.tcti.clone(),
556            }),
557            _ => Err(crate::error::Error::Config(format!(
558                "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
559                self.backend_type
560            ))),
561        }
562    }
563
564    /// Apply security hardening measures
565    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
566        self.security.apply_hardening()
567    }
568
569    /// Get security configuration
570    pub fn security_config(&self) -> SecurityConfig {
571        self.security.clone()
572    }
573
574    /// Get PIN configuration
575    pub fn pin_config(&self) -> PinConfig {
576        self.pin.clone()
577    }
578
579    /// Validate the configuration
580    pub fn validate(&self) -> crate::error::Result<()> {
581        self.pin.validate()?;
582        Ok(())
583    }
584}
585
586/// CLI arguments structure
587#[derive(Parser)]
588#[command(author, version, about)]
589pub struct Args {
590    /// Path to configuration file (TOML format)
591    #[arg(short, long, env = "PASSLESS_CONFIG")]
592    pub config_path: Option<PathBuf>,
593
594    /// Application configuration (can come from CLI or config file)
595    #[command(flatten)]
596    pub config: <AppConfig as ClapSerde>::Opt,
597
598    /// Subcommands
599    #[command(subcommand)]
600    pub command: Option<Commands>,
601}
602
603/// Output format for client commands
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub enum OutputFormat {
606    /// Human-readable plain text output
607    Plain,
608    /// JSON output for programmatic consumption
609    Json,
610}
611
612impl std::str::FromStr for OutputFormat {
613    type Err = String;
614
615    fn from_str(s: &str) -> Result<Self, Self::Err> {
616        match s.to_lowercase().as_str() {
617            "plain" => Ok(OutputFormat::Plain),
618            "json" => Ok(OutputFormat::Json),
619            _ => Err(format!(
620                "Invalid output format '{}'. Must be 'plain' or 'json'",
621                s
622            )),
623        }
624    }
625}
626
627impl std::fmt::Display for OutputFormat {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        match self {
630            OutputFormat::Plain => write!(f, "plain"),
631            OutputFormat::Json => write!(f, "json"),
632        }
633    }
634}
635
636/// Subcommands for passless
637#[derive(Subcommand, Debug, Clone)]
638pub enum Commands {
639    /// Configuration management commands
640    Config {
641        #[command(subcommand)]
642        action: ConfigAction,
643    },
644    /// FIDO2 client commands for managing authenticators
645    ///
646    /// These commands require a running authenticator. For testing:
647    /// 1. Start authenticator: PASSLESS_E2E_AUTO_ACCEPT_UV=1 cargo run -- --backend-type local
648    /// 2. Run client commands in another terminal with the same environment variable
649    Client {
650        /// Select device by index (0-based) or name. Use 'devices' subcommand to list available devices.
651        #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
652        device: Option<String>,
653
654        /// Output format: plain (default) or json
655        #[arg(
656            short = 'o',
657            long = "output",
658            value_name = "FORMAT",
659            default_value = "plain",
660            global = true
661        )]
662        output: OutputFormat,
663
664        #[command(subcommand)]
665        action: ClientAction,
666    },
667}
668
669/// Configuration actions
670#[derive(Subcommand, Debug, Clone)]
671pub enum ConfigAction {
672    /// Print the default configuration in TOML format
673    Print,
674}
675
676/// Client actions for FIDO2 authenticator management
677#[derive(Subcommand, Debug, Clone)]
678pub enum ClientAction {
679    /// List all available FIDO2 authenticators/devices
680    Devices,
681    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
682    Info,
683    /// Reset the authenticator (WARNING: deletes ALL credentials)
684    Reset {
685        /// Confirmation flag that must be provided twice for safety
686        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
687        confirm: u8,
688    },
689    /// List all credentials on the authenticator
690    List {
691        /// Filter by relying party ID (domain)
692        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
693        rp_id: Option<String>,
694    },
695    /// Show detailed information about a specific credential
696    Show {
697        /// Credential ID in hexadecimal format
698        #[arg(value_name = "CREDENTIAL_ID")]
699        credential_id: String,
700    },
701    /// Delete a specific credential by ID
702    Delete {
703        /// Credential ID in hexadecimal format
704        #[arg(value_name = "CREDENTIAL_ID")]
705        credential_id: String,
706    },
707    /// Rename a credential (update user name and/or display name)
708    Rename {
709        /// Credential ID in hexadecimal format
710        #[arg(value_name = "CREDENTIAL_ID")]
711        credential_id: String,
712        /// New user name (login identifier)
713        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
714        user_name: Option<String>,
715        /// New display name (friendly name)
716        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
717        display_name: Option<String>,
718    },
719    /// PIN management commands
720    Pin {
721        #[command(subcommand)]
722        action: PinAction,
723    },
724}
725
726/// PIN management actions
727#[derive(Subcommand, Debug, Clone)]
728pub enum PinAction {
729    /// Set a new PIN (authenticator must not have a PIN set)
730    Set {
731        /// The new PIN (minimum 4 characters)
732        #[arg(value_name = "PIN")]
733        pin: String,
734    },
735    /// Change the existing PIN
736    Change {
737        /// The current PIN
738        #[arg(value_name = "OLD_PIN")]
739        old_pin: String,
740        /// The new PIN (minimum 4 characters)
741        #[arg(value_name = "NEW_PIN")]
742        new_pin: String,
743    },
744    /// Reset built-in user verification retries without deleting credentials
745    UvReset,
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_pin_config_default_max_uv_retries() {
754        let config = PinConfig {
755            enforcement: PinEnforcement::Optional,
756            min_length: 4,
757            max_retries: 8,
758            max_uv_retries: 8,
759            auto_lock_timeout: 0,
760        };
761        assert_eq!(config.max_uv_retries, 8);
762    }
763
764    #[test]
765    fn test_pin_config_validate_success() {
766        let config = PinConfig {
767            enforcement: PinEnforcement::Optional,
768            min_length: 4,
769            max_retries: 8,
770            max_uv_retries: 8,
771            auto_lock_timeout: 0,
772        };
773        assert!(config.validate().is_ok());
774    }
775
776    #[test]
777    fn test_pin_config_validate_zero_max_uv_retries() {
778        let config = PinConfig {
779            enforcement: PinEnforcement::Optional,
780            min_length: 4,
781            max_retries: 8,
782            max_uv_retries: 0,
783            auto_lock_timeout: 0,
784        };
785        let result = config.validate();
786        assert!(result.is_err());
787        assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
788    }
789
790    #[test]
791    fn test_pin_config_validate_zero_max_retries() {
792        let config = PinConfig {
793            enforcement: PinEnforcement::Optional,
794            min_length: 4,
795            max_retries: 0,
796            max_uv_retries: 8,
797            auto_lock_timeout: 0,
798        };
799        let result = config.validate();
800        assert!(result.is_err());
801        assert!(result.unwrap_err().to_string().contains("max_retries"));
802    }
803
804    #[test]
805    fn test_pin_config_validate_invalid_min_length() {
806        let config = PinConfig {
807            enforcement: PinEnforcement::Optional,
808            min_length: 3,
809            max_retries: 8,
810            max_uv_retries: 8,
811            auto_lock_timeout: 0,
812        };
813        let result = config.validate();
814        assert!(result.is_err());
815        assert!(result.unwrap_err().to_string().contains("min_length"));
816    }
817
818    #[test]
819    fn test_canonicalize_path_existing() {
820        let dir = std::env::temp_dir();
821        let canonical = BackendConfig::canonicalize_path(&dir);
822        assert!(canonical.is_absolute());
823        assert!(canonical.exists());
824    }
825
826    #[test]
827    fn test_canonicalize_path_nonexistent() {
828        let base = std::env::temp_dir();
829        let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
830        let canonical = BackendConfig::canonicalize_path(&nonexistent);
831        assert!(canonical.is_absolute());
832        assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
833    }
834
835    #[test]
836    fn test_canonicalize_path_symlink() {
837        let dir = tempfile::tempdir().unwrap();
838        let real = dir.path().join("real");
839        std::fs::create_dir(&real).unwrap();
840        let link = dir.path().join("link");
841        std::os::unix::fs::symlink(&real, &link).unwrap();
842
843        let canonical_real = BackendConfig::canonicalize_path(&real);
844        let canonical_link = BackendConfig::canonicalize_path(&link);
845        assert_eq!(canonical_real, canonical_link);
846    }
847
848    #[test]
849    fn test_local_state_path_relative_and_absolute() {
850        let dir = tempfile::tempdir_in(".").unwrap();
851        let abs_path = std::fs::canonicalize(dir.path()).unwrap();
852        let rel_path = dir.path().to_path_buf();
853
854        let backend_abs = BackendConfig::Local {
855            path: abs_path.display().to_string(),
856        };
857        let backend_rel = BackendConfig::Local {
858            path: rel_path.display().to_string(),
859        };
860        assert_eq!(backend_abs.state_path(), backend_rel.state_path());
861    }
862
863    #[test]
864    fn test_pass_state_path_different_subpaths() {
865        let store = "/tmp/passless_test_store";
866        let backend_a = BackendConfig::Pass {
867            store_path: store.to_string(),
868            path: "fido2".to_string(),
869            gpg_backend: "gnupg-bin".to_string(),
870        };
871        let backend_b = BackendConfig::Pass {
872            store_path: store.to_string(),
873            path: "fido2-other".to_string(),
874            gpg_backend: "gnupg-bin".to_string(),
875        };
876        assert_ne!(backend_a.state_path(), backend_b.state_path());
877    }
878
879    #[test]
880    fn test_different_local_paths_produce_different_identities() {
881        let backend_a = BackendConfig::Local {
882            path: "/tmp/passless_a".to_string(),
883        };
884        let backend_b = BackendConfig::Local {
885            path: "/tmp/passless_b".to_string(),
886        };
887        assert_ne!(backend_a.state_path(), backend_b.state_path());
888    }
889}