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, ValueEnum};
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#[cfg(feature = "agent")]
22use crate::agent::AgentConfig;
23
24use crate::error::Error;
25
26/// Compute default local storage path
27pub fn local_path() -> String {
28    dirs::data_dir()
29        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
30        .join("passless/local")
31        .to_string_lossy()
32        .into_owned()
33}
34
35/// Local backend configuration
36#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
37#[group(id = "local-backend-config")]
38pub struct LocalBackendConfig {
39    /// Path to local storage directory
40    #[arg(
41        long = "local-path",
42        env = "PASSLESS_LOCAL_PATH",
43        id = "local-path",
44        value_name = "PATH"
45    )]
46    #[serde(default)]
47    #[default(local_path())]
48    pub path: String,
49}
50
51/// Compute default password-store path
52pub fn pass_store_path() -> String {
53    dirs::home_dir()
54        .expect("Could not determine home directory: $HOME")
55        .join(".password-store")
56        .to_string_lossy()
57        .into_owned()
58}
59/// Pass (password-store) backend configuration
60#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
61#[group(id = "pass-backend-config")]
62pub struct PassBackendConfig {
63    /// Path to password store directory
64    #[arg(
65        long = "pass-store-path",
66        env = "PASSLESS_PASS_STORE_PATH",
67        id = "pass-store-path",
68        value_name = "PATH"
69    )]
70    #[serde(default)]
71    #[default(pass_store_path())]
72    pub store_path: String,
73
74    /// Relative path within password store for FIDO2 entries
75    #[arg(
76        long = "pass-path",
77        env = "PASSLESS_PASS_PATH",
78        id = "pass-path",
79        value_name = "PATH"
80    )]
81    #[serde(default)]
82    #[default("fido2".to_string())]
83    pub path: String,
84
85    /// GPG backend: "gpgme" or "gnupg-bin"
86    #[arg(
87        long = "pass-gpg-backend",
88        env = "PASSLESS_PASS_GPG_BACKEND",
89        value_name = "BACKEND"
90    )]
91    #[serde(default)]
92    #[default("gnupg-bin".to_string())]
93    pub gpg_backend: String,
94}
95
96/// Compute default TPM storage path
97pub fn tpm_path() -> String {
98    dirs::data_dir()
99        .expect("Could not determine data directory: $XDG_DATA_HOME or $HOME/.local/share")
100        .join("passless/tpm")
101        .to_string_lossy()
102        .into_owned()
103}
104
105/// TPM backend configuration
106#[cfg(feature = "tpm")]
107#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
108#[group(id = "tpm-backend-config")]
109pub struct TpmBackendConfig {
110    /// Path to TPM storage directory
111    #[arg(
112        long = "tpm-path",
113        env = "PASSLESS_TPM_PATH",
114        id = "tpm-path",
115        value_name = "PATH"
116    )]
117    #[serde(default)]
118    #[default(tpm_path())]
119    pub path: String,
120
121    /// TPM TCTI (TPM Command Transmission Interface) configuration
122    #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI", value_name = "TCTI")]
123    #[serde(default)]
124    #[default("device:/dev/tpmrm0".to_string())]
125    pub tcti: String,
126
127    /// Use portable TPM backend with TPM-resident credential keys
128    #[arg(long = "tpm-portable", env = "PASSLESS_TPM_PORTABLE")]
129    #[serde(default)]
130    #[default(false)]
131    pub portable: bool,
132}
133
134/// Security configuration
135#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
136#[group(id = "security")]
137pub struct SecurityConfig {
138    /// Check if mlock is available to prevent credentials from being swapped to disk
139    #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
140    #[serde(default)]
141    #[default(true)]
142    pub check_mlock: bool,
143
144    /// Disable core dumps to prevent credential leakage
145    #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
146    #[serde(default)]
147    #[default(true)]
148    pub disable_core_dumps: bool,
149
150    /// Enable constant signature counter to help RPs detect cloned authenticators
151    #[arg(
152        long = "constant-signature-counter",
153        env = "PASSLESS_CONSTANT_SIGNATURE_COUNTER",
154        action = ArgAction::Set,
155        require_equals = true,
156        num_args = 0..=1,
157        default_missing_value = "true"
158    )]
159    #[serde(default)]
160    pub constant_signature_counter: bool,
161
162    /// Enable Passless encrypted credential backup/restore vendor commands.
163    ///
164    /// Also sets the WebAuthn backup-eligibility (BE) flag assigned to newly
165    /// registered credentials: `false` (default) marks them single-device
166    /// (`BE=0`), `true` marks them backup-eligible (`BE=1`). Existing
167    /// credentials are not migrated; they keep the state they were created with.
168    ///
169    /// Disabled by default because exporting a software credential changes its
170    /// security model. TPM and other non-exportable key providers remain
171    /// unsupported. Strict relying parties (e.g. Kanidm) reject a credential
172    /// whose BE flag changes after registration, so keep this setting stable.
173    /// See docs/CREDENTIAL_BACKUP.md.
174    #[arg(
175        long = "enable-credential-backup",
176        env = "PASSLESS_ENABLE_CREDENTIAL_BACKUP",
177        action = ArgAction::Set,
178        require_equals = true,
179        num_args = 0..=1,
180        default_missing_value = "true"
181    )]
182    #[serde(default)]
183    pub enable_credential_backup: bool,
184
185    /// Always require user verification for all operations
186    /// - When PIN is set + pin.enforcement="required": requires PIN
187    /// - When PIN is set + pin.enforcement="optional": depends on context
188    /// - When PIN is set + pin.enforcement="never": uses notification fallback
189    /// - When PIN not set: uses notification
190    #[arg(
191        long = "always-uv",
192        env = "PASSLESS_ALWAYS_UV",
193        action = ArgAction::Set,
194        require_equals = true,
195        num_args = 0..=1,
196        default_value = "true",
197        default_missing_value = "true"
198    )]
199    #[serde(default)]
200    #[default(true)]
201    pub always_uv: bool,
202
203    /// Show user verification notification during registration
204    #[arg(
205        long = "user-verification-registration",
206        env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
207    )]
208    #[serde(default)]
209    #[default(true)]
210    pub user_verification_registration: bool,
211
212    /// Show user verification notification during authentication
213    #[arg(
214        long = "user-verification-authentication",
215        env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
216    )]
217    #[serde(default)]
218    #[default(true)]
219    pub user_verification_authentication: bool,
220
221    /// Notification timeout in seconds (0 = no timeout)
222    #[arg(
223        long = "notification-timeout",
224        env = "PASSLESS_NOTIFICATION_TIMEOUT",
225        value_name = "SECONDS"
226    )]
227    #[serde(default)]
228    #[default(30)]
229    pub notification_timeout: u32,
230}
231
232/// PIN enforcement policy
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "lowercase")]
235pub enum PinEnforcement {
236    /// Never require PIN, always use notification fallback (backward compatible)
237    Never,
238    /// Use PIN only when always_uv=true or client requests UV
239    #[default]
240    Optional,
241    /// Always require PIN when set (most secure)
242    Required,
243}
244
245impl std::str::FromStr for PinEnforcement {
246    type Err = String;
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        match s.to_lowercase().as_str() {
250            "never" => Ok(PinEnforcement::Never),
251            "optional" => Ok(PinEnforcement::Optional),
252            "required" => Ok(PinEnforcement::Required),
253            _ => Err(format!(
254                "Invalid PIN enforcement '{}'. Must be: never, optional, or required",
255                s
256            )),
257        }
258    }
259}
260
261impl std::fmt::Display for PinEnforcement {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            PinEnforcement::Never => write!(f, "never"),
265            PinEnforcement::Optional => write!(f, "optional"),
266            PinEnforcement::Required => write!(f, "required"),
267        }
268    }
269}
270
271/// PIN configuration
272#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
273#[group(id = "pin")]
274pub struct PinConfig {
275    /// PIN enforcement policy when PIN is set:
276    /// - "never": Always use notification fallback (backward compatible, convenience)
277    /// - "optional": Use PIN only when always_uv=true or client requests UV
278    /// - "required": Always require PIN when set (most secure)
279    #[arg(
280        long = "pin-enforcement",
281        env = "PASSLESS_PIN_ENFORCEMENT",
282        value_name = "POLICY"
283    )]
284    #[serde(default)]
285    #[default(PinEnforcement::Optional)]
286    pub enforcement: PinEnforcement,
287
288    /// Minimum PIN length in characters (CTAP spec: 4-63)
289    #[arg(
290        long = "pin-min-length",
291        env = "PASSLESS_PIN_MIN_LENGTH",
292        value_name = "LENGTH"
293    )]
294    #[serde(default)]
295    #[default(4)]
296    pub min_length: u8,
297
298    /// Maximum PIN retry attempts before lockout (CTAP spec: 8)
299    #[arg(
300        long = "pin-max-retries",
301        env = "PASSLESS_PIN_MAX_RETRIES",
302        value_name = "RETRIES"
303    )]
304    #[serde(default)]
305    #[default(8)]
306    pub max_retries: u8,
307
308    /// Maximum user verification retry attempts before UV is blocked (default: 8)
309    ///
310    /// This controls how many consecutive UV failures are allowed before UV is blocked.
311    /// Use `passless client pin uv-reset` to restore the retry counter after authentication.
312    /// Higher values improve usability but may reduce security against brute-force attacks.
313    #[arg(
314        long = "pin-max-uv-retries",
315        env = "PASSLESS_PIN_MAX_UV_RETRIES",
316        value_name = "RETRIES"
317    )]
318    #[serde(default)]
319    #[default(8)]
320    pub max_uv_retries: u8,
321
322    /// Auto-lock timeout in seconds after max failed attempts (0 = disabled)
323    /// After lockout, authenticator must be reset to use PIN again
324    #[arg(
325        long = "pin-auto-lock-timeout",
326        env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
327        value_name = "SECONDS"
328    )]
329    #[serde(default)]
330    #[default(0)]
331    pub auto_lock_timeout: u32,
332}
333
334impl PinConfig {
335    /// Validate PIN configuration values
336    pub fn validate(&self) -> crate::error::Result<()> {
337        if self.min_length < 4 || self.min_length > 63 {
338            return Err(crate::error::Error::Config(format!(
339                "pin.min_length must be between 4 and 63, got {}",
340                self.min_length
341            )));
342        }
343        if self.max_retries == 0 {
344            return Err(crate::error::Error::Config(
345                "pin.max_retries must be greater than 0".to_string(),
346            ));
347        }
348        if self.max_uv_retries == 0 {
349            return Err(crate::error::Error::Config(
350                "pin.max_uv_retries must be greater than 0".to_string(),
351            ));
352        }
353        Ok(())
354    }
355}
356
357impl SecurityConfig {
358    /// Apply security hardening measures
359    pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
360        if self.disable_core_dumps {
361            self.disable_core_dumps_impl()?;
362        }
363        if self.check_mlock {
364            self.probe_mlock_capability()?;
365        }
366        Ok(())
367    }
368
369    /// Disable core dumps to prevent credential leakage
370    fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
371        debug!("Disabling core dumps to prevent credential leakage");
372        setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
373        let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
374        if r != 0 {
375            log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
376        }
377        Ok(())
378    }
379
380    /// Probe mlock capability by testing with a small allocation
381    fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
382        debug!("Check mlock capability");
383
384        let test_size = 4096;
385        let test_buffer = vec![0u8; test_size];
386        let ptr = test_buffer.as_ptr() as *const libc::c_void;
387
388        let lock_result = unsafe { mlock(ptr, test_size) };
389
390        if lock_result == 0 {
391            unsafe { munlock(ptr, test_size) };
392            log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
393        } else {
394            log::warn!(
395                "mlock capability probe failed - memory locking may not be available.\n\
396                 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
397            );
398        }
399        Ok(())
400    }
401}
402
403/// Main application configuration
404/// Note: Cannot derive Clone/Debug because it has #[clap_serde] fields
405#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
406pub struct AppConfig {
407    /// Storage backend type: pass, tpm (experimental), or local (for testing)
408    #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
409    #[serde(default)]
410    #[default("pass".to_string())]
411    pub backend_type: String,
412
413    /// Enable verbose logging
414    // workaround for allowing `-v` syntax instead of `-v=true`
415    #[arg(
416        short,
417        long,
418        env = "PASSLESS_VERBOSE",
419        action = ArgAction::Set,
420        require_equals = true,
421        num_args = 0..=1,
422        default_missing_value = "true"
423    )]
424    #[default(true)]
425    #[serde(default)]
426    pub verbose: bool,
427
428    /// Pass backend configuration
429    #[clap_serde]
430    #[serde(default)]
431    #[command(flatten)]
432    pub pass: PassBackendConfig,
433
434    /// TPM backend configuration
435    #[cfg(feature = "tpm")]
436    #[clap_serde]
437    #[serde(default)]
438    #[command(flatten)]
439    pub tpm: TpmBackendConfig,
440
441    /// Local backend configuration
442    #[clap_serde]
443    #[serde(default)]
444    #[command(flatten)]
445    pub local: LocalBackendConfig,
446
447    /// Security hardening configuration
448    #[clap_serde]
449    #[serde(default)]
450    #[command(flatten)]
451    pub security: SecurityConfig,
452
453    /// PIN configuration
454    #[clap_serde]
455    #[serde(default)]
456    #[command(flatten)]
457    pub pin: PinConfig,
458
459    /// Agent configuration (only available with the `agent` feature)
460    #[cfg(feature = "agent")]
461    #[arg(skip)]
462    pub agents: AgentConfig,
463}
464
465/// Backend-specific configuration
466#[derive(Debug, Clone)]
467pub enum BackendConfig {
468    Local {
469        path: String,
470    },
471    Pass {
472        store_path: String,
473        path: String,
474        gpg_backend: String,
475    },
476    #[cfg(feature = "tpm")]
477    Tpm {
478        path: String,
479        tcti: String,
480        portable: bool,
481    },
482}
483
484impl BackendConfig {
485    /// Canonicalize a path, resolving symlinks for existing parents.
486    ///
487    /// If the full path does not exist, canonicalize the longest existing
488    /// prefix and append the remaining components lexically.
489    pub fn canonicalize_path(path: &Path) -> PathBuf {
490        match fs::canonicalize(path) {
491            Ok(p) => p,
492            Err(_) => {
493                let mut current = path.to_path_buf();
494                let mut suffix = Vec::new();
495                loop {
496                    match fs::canonicalize(&current) {
497                        Ok(base) => {
498                            let mut result = base;
499                            for component in suffix.iter().rev() {
500                                result.push(component);
501                            }
502                            return result;
503                        }
504                        Err(_) => {
505                            if let Some(file_name) = current.file_name() {
506                                suffix.push(file_name.to_os_string());
507                                current = current
508                                    .parent()
509                                    .map(|p| p.to_path_buf())
510                                    .unwrap_or_default();
511                            } else {
512                                return path.to_path_buf();
513                            }
514                        }
515                    }
516                }
517            }
518        }
519    }
520
521    /// Return the canonical state path for this backend.
522    ///
523    /// This is used as the identity for the instance lock: two daemons with the
524    /// same canonical state path will contend for the same lock.
525    pub fn state_path(&self) -> PathBuf {
526        match self {
527            BackendConfig::Local { path } => Self::canonicalize_path(Path::new(path)),
528            BackendConfig::Pass {
529                store_path, path, ..
530            } => Self::canonicalize_path(&Path::new(store_path).join(path)),
531            #[cfg(feature = "tpm")]
532            BackendConfig::Tpm { path, .. } => Self::canonicalize_path(Path::new(path)),
533        }
534    }
535
536    /// Return a human-readable display string for the backend state path.
537    pub fn state_display(&self) -> String {
538        match self {
539            BackendConfig::Local { path } => path.clone(),
540            BackendConfig::Pass {
541                store_path, path, ..
542            } => {
543                format!("{}/{}", store_path, path)
544            }
545            #[cfg(feature = "tpm")]
546            BackendConfig::Tpm { path, .. } => path.clone(),
547        }
548    }
549
550    /// Validate backend configuration for security and correctness.
551    ///
552    /// This checks that paths are well-formed and don't escape their intended roots.
553    pub fn validate(&self) -> crate::error::Result<()> {
554        match self {
555            BackendConfig::Local { path } => {
556                let p = Path::new(path);
557                if !p.is_absolute() && !p.starts_with("~") {
558                    debug!("Local backend path is relative: {}, canonicalizing", path);
559                }
560                Ok(())
561            }
562            BackendConfig::Pass {
563                store_path, path, ..
564            } => {
565                let p = Path::new(path);
566                if p.is_absolute() {
567                    return Err(Error::Config(format!(
568                        "Pass backend 'path' must be relative, got absolute path: {}",
569                        path
570                    )));
571                }
572                if path.contains("..") {
573                    return Err(Error::Config(format!(
574                        "Pass backend 'path' must not contain '..': {}",
575                        path
576                    )));
577                }
578                // Verify the combined path doesn't escape the store
579                let combined = Path::new(store_path).join(path);
580                let canonical_store = Self::canonicalize_path(Path::new(store_path));
581                let canonical_combined = Self::canonicalize_path(&combined);
582                if !canonical_combined.starts_with(&canonical_store) {
583                    return Err(Error::Config(format!(
584                        "Pass backend 'path' escapes store_path: {} not beneath {}",
585                        canonical_combined.display(),
586                        canonical_store.display()
587                    )));
588                }
589                Ok(())
590            }
591            #[cfg(feature = "tpm")]
592            BackendConfig::Tpm { path, .. } => {
593                let p = Path::new(path);
594                if !p.is_absolute() && !p.starts_with("~") {
595                    debug!("TPM backend path is relative: {}, canonicalizing", path);
596                }
597                Ok(())
598            }
599        }
600    }
601}
602
603impl AppConfig {
604    /// Load configuration with precedence: CLI > config file > defaults
605    pub fn load(args: &mut Args) -> crate::error::Result<Self> {
606        let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
607
608        let config_file_path = args
609            .config_path
610            .as_ref()
611            .or(default_config_path.as_ref())
612            .filter(|p| p.exists());
613
614        if let Some(path) = config_file_path
615            && let Ok(f) = File::open(path)
616        {
617            log::info!("Loading configuration from: {}", path.display());
618            let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
619
620            #[cfg(feature = "agent")]
621            let agent_config = {
622                match toml::from_str::<toml::Table>(&content) {
623                    Ok(table) => match table.get("agents") {
624                        Some(agents_value) => serde::Deserialize::deserialize(agents_value.clone())
625                            .map_err(|e| {
626                                Error::Config(format!(
627                                    "failed to parse [agents] section in {}: {}",
628                                    path.display(),
629                                    e
630                                ))
631                            })?,
632                        None => AgentConfig::default(),
633                    },
634                    Err(e) => {
635                        return Err(Error::Config(format!(
636                            "failed to parse config file {} as TOML: {}",
637                            path.display(),
638                            e
639                        )));
640                    }
641                }
642            };
643
644            match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
645                Ok(file_config) => {
646                    #[allow(unused_mut)]
647                    let mut config = AppConfig::from(file_config).merge(&mut args.config);
648                    #[cfg(feature = "agent")]
649                    {
650                        config.agents = agent_config;
651                    }
652                    return Ok(config);
653                }
654                Err(e) => {
655                    return Err(Error::Config(format!(
656                        "failed to parse config file {}: {}",
657                        path.display(),
658                        e
659                    )));
660                }
661            }
662        }
663
664        #[allow(unused_mut)]
665        let mut config = AppConfig::from(&mut args.config);
666        #[cfg(feature = "agent")]
667        {
668            config.agents = AgentConfig::default();
669        }
670        Ok(config)
671    }
672
673    /// Get the backend configuration based on the backend_type
674    pub fn backend(&self) -> crate::error::Result<BackendConfig> {
675        match self.backend_type.as_str() {
676            "local" => Ok(BackendConfig::Local {
677                path: self.local.path.clone(),
678            }),
679            "pass" => Ok(BackendConfig::Pass {
680                store_path: self.pass.store_path.clone(),
681                path: self.pass.path.clone(),
682                gpg_backend: self.pass.gpg_backend.clone(),
683            }),
684            #[cfg(feature = "tpm")]
685            "tpm" => Ok(BackendConfig::Tpm {
686                path: self.tpm.path.clone(),
687                tcti: self.tpm.tcti.clone(),
688                portable: self.tpm.portable,
689            }),
690            _ => Err(crate::error::Error::Config(format!(
691                "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
692                self.backend_type
693            ))),
694        }
695    }
696
697    /// Apply security hardening measures
698    pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
699        self.security.apply_hardening()
700    }
701
702    /// Get security configuration
703    pub fn security_config(&self) -> SecurityConfig {
704        self.security.clone()
705    }
706
707    /// Get PIN configuration
708    pub fn pin_config(&self) -> PinConfig {
709        self.pin.clone()
710    }
711
712    /// Validate the configuration
713    pub fn validate(&self) -> crate::error::Result<()> {
714        self.pin.validate()?;
715        #[cfg(feature = "agent")]
716        {
717            let human_path = self.backend().ok().map(|b| b.state_path());
718            self.agents.validate(human_path.as_deref())?;
719        }
720        Ok(())
721    }
722}
723
724/// CLI arguments structure
725#[derive(Parser)]
726#[command(author, version, about)]
727pub struct Args {
728    /// Path to configuration file (TOML format)
729    #[arg(short, long, env = "PASSLESS_CONFIG")]
730    pub config_path: Option<PathBuf>,
731
732    /// Application configuration (can come from CLI or config file)
733    #[command(flatten)]
734    pub config: <AppConfig as ClapSerde>::Opt,
735
736    /// Subcommands
737    #[command(subcommand)]
738    pub command: Option<Commands>,
739}
740
741/// Output format for client commands
742#[derive(Debug, Clone, Copy, PartialEq, Eq)]
743pub enum OutputFormat {
744    /// Human-readable plain text output
745    Plain,
746    /// JSON output for programmatic consumption
747    Json,
748}
749
750impl std::str::FromStr for OutputFormat {
751    type Err = String;
752
753    fn from_str(s: &str) -> Result<Self, Self::Err> {
754        match s.to_lowercase().as_str() {
755            "plain" => Ok(OutputFormat::Plain),
756            "json" => Ok(OutputFormat::Json),
757            _ => Err(format!(
758                "Invalid output format '{}'. Must be 'plain' or 'json'",
759                s
760            )),
761        }
762    }
763}
764
765impl std::fmt::Display for OutputFormat {
766    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767        match self {
768            OutputFormat::Plain => write!(f, "plain"),
769            OutputFormat::Json => write!(f, "json"),
770        }
771    }
772}
773
774/// Subcommands for passless
775#[derive(Subcommand, Debug, Clone)]
776pub enum Commands {
777    /// Configuration management commands
778    Config {
779        #[command(subcommand)]
780        action: ConfigAction,
781    },
782    /// FIDO2 client commands for managing authenticators
783    ///
784    /// These commands require a running authenticator. For testing:
785    /// 1. Start authenticator: PASSLESS_E2E_AUTO_ACCEPT_UV=1 cargo run -- --backend-type local
786    /// 2. Run client commands in another terminal with the same environment variable
787    Client {
788        /// Select device by index (0-based) or name. Use 'devices' subcommand to list available devices.
789        #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
790        device: Option<String>,
791
792        /// Output format: plain (default) or json
793        #[arg(
794            short = 'o',
795            long = "output",
796            value_name = "FORMAT",
797            default_value = "plain",
798            global = true
799        )]
800        output: OutputFormat,
801
802        #[command(subcommand)]
803        action: ClientAction,
804    },
805    /// Agent administration commands
806    #[cfg(feature = "agent")]
807    AgentAdmin {
808        /// Output format: json (default) or plain
809        #[arg(
810            short = 'o',
811            long = "output",
812            value_name = "FORMAT",
813            default_value = "json",
814            global = true
815        )]
816        output: OutputFormat,
817
818        #[command(subcommand)]
819        action: AgentAdminAction,
820    },
821    /// Agent principal and session commands
822    #[cfg(feature = "agent")]
823    Agent {
824        /// Profile to use for principal commands
825        #[arg(long, value_name = "PROFILE", global = true)]
826        profile: Option<String>,
827
828        /// Output format: json (default) or plain
829        #[arg(
830            short = 'o',
831            long = "output",
832            value_name = "FORMAT",
833            default_value = "json",
834            global = true
835        )]
836        output: OutputFormat,
837
838        #[command(subcommand)]
839        action: crate::AgentCommand,
840    },
841    /// TPM portable parent management
842    #[cfg(feature = "tpm")]
843    Tpm {
844        #[command(subcommand)]
845        action: TpmAction,
846    },
847}
848
849/// TPM portable parent actions
850#[cfg(feature = "tpm")]
851#[derive(Subcommand, Debug, Clone)]
852pub enum TpmAction {
853    /// Provision the portable TPM parent from a recovery seed
854    #[command(group(clap::ArgGroup::new("seed-source").args(["generate", "seed_file", "seed_stdin"])))]
855    Provision {
856        /// Generate a new random 32-byte recovery seed and print it
857        #[arg(long)]
858        generate: bool,
859        /// Read the recovery seed (hex) from this file instead of prompting
860        #[arg(long = "seed-file", value_name = "PATH")]
861        seed_file: Option<PathBuf>,
862        /// Read the recovery seed (hex) from stdin instead of prompting
863        #[arg(long = "seed-stdin")]
864        seed_stdin: bool,
865        /// TPM storage directory
866        #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
867        path: Option<String>,
868        /// TPM TCTI configuration
869        #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
870        tcti: Option<String>,
871    },
872    /// Show provisioning status
873    Status {
874        /// TPM storage directory
875        #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
876        path: Option<String>,
877        /// TPM TCTI configuration
878        #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
879        tcti: Option<String>,
880    },
881    /// Remove the provisioned portable parent
882    Remove {
883        /// Confirm the removal
884        #[arg(long)]
885        confirm: bool,
886        /// TPM storage directory
887        #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
888        path: Option<String>,
889        /// TPM TCTI configuration
890        #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
891        tcti: Option<String>,
892    },
893    /// Migrate legacy sealed credentials to portable TPM format
894    #[command(group(clap::ArgGroup::new("selection").args(["credential_id", "all"]).required(true)))]
895    Migrate {
896        /// Migrate a specific credential by ID (hex)
897        #[arg(long = "credential-id", value_name = "ID")]
898        credential_id: Option<String>,
899        /// Migrate all migratable legacy credentials
900        #[arg(long)]
901        all: bool,
902        /// Show what would be migrated without making changes
903        #[arg(long)]
904        dry_run: bool,
905        /// Directory to store backups of legacy records
906        #[arg(long = "backup-dir", value_name = "PATH")]
907        backup_dir: Option<String>,
908        /// TPM storage directory
909        #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
910        path: Option<String>,
911        /// TPM TCTI configuration
912        #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
913        tcti: Option<String>,
914    },
915}
916
917/// Configuration actions
918#[derive(Subcommand, Debug, Clone)]
919pub enum ConfigAction {
920    /// Print the default configuration in TOML format
921    Print,
922}
923
924/// Agent administration actions
925#[cfg(feature = "agent")]
926#[derive(Subcommand, Debug, Clone)]
927pub enum AgentAdminAction {
928    /// Install the Passless skill for a supported coding agent
929    Install {
930        /// Agent to install for; auto installs to every detected agent
931        #[arg(value_enum, default_value_t = AgentSkillTarget::Auto)]
932        target: AgentSkillTarget,
933
934        /// Install for the current user or the current Git worktree
935        #[arg(long, value_enum, default_value_t = AgentSkillScope::User)]
936        scope: AgentSkillScope,
937
938        /// Replace a different existing file at the skill target
939        #[arg(long)]
940        force: bool,
941    },
942    /// Profile management
943    Profile {
944        #[command(subcommand)]
945        action: AdminProfileAction,
946    },
947    /// Policy management
948    Policy {
949        #[command(subcommand)]
950        action: AdminPolicyAction,
951    },
952    /// Credential management
953    Credential {
954        #[command(subcommand)]
955        action: AdminCredentialAction,
956    },
957    /// Delegation management
958    Delegation {
959        #[command(subcommand)]
960        action: AdminDelegationAction,
961    },
962    /// Session management
963    Session {
964        #[command(subcommand)]
965        action: AdminSessionAction,
966    },
967    /// Audit log management
968    Audit {
969        #[command(subcommand)]
970        action: AdminAuditAction,
971    },
972    /// Shut down the running daemon
973    #[command(hide = true)]
974    Shutdown {
975        /// Confirm the shutdown
976        #[arg(long)]
977        confirm: bool,
978    },
979}
980
981/// Admin profile actions
982#[cfg(feature = "agent")]
983#[derive(Subcommand, Debug, Clone)]
984pub enum AdminProfileAction {
985    /// Check if a profile exists and is valid
986    Check {
987        /// Profile identifier
988        #[arg(value_name = "PROFILE")]
989        profile: String,
990    },
991    /// Show profile details
992    Show {
993        /// Profile identifier
994        #[arg(value_name = "PROFILE")]
995        profile: String,
996    },
997    /// List all configured profiles
998    List,
999    /// Enable a profile
1000    Enable {
1001        /// Profile identifier
1002        #[arg(value_name = "PROFILE")]
1003        profile: String,
1004    },
1005    /// Disable a profile
1006    Disable {
1007        /// Profile identifier
1008        #[arg(value_name = "PROFILE")]
1009        profile: String,
1010    },
1011}
1012
1013/// Admin policy actions
1014#[cfg(feature = "agent")]
1015#[derive(Subcommand, Debug, Clone)]
1016pub enum AdminPolicyAction {
1017    /// Check policy validity for a profile
1018    Check {
1019        /// Profile identifier
1020        #[arg(value_name = "PROFILE")]
1021        profile: String,
1022    },
1023    /// Reload policy for a profile
1024    Reload {
1025        /// Profile identifier
1026        #[arg(value_name = "PROFILE")]
1027        profile: String,
1028    },
1029    /// Show current policy for a profile
1030    Show {
1031        /// Profile identifier
1032        #[arg(value_name = "PROFILE")]
1033        profile: String,
1034    },
1035}
1036
1037/// Admin credential actions
1038#[cfg(feature = "agent")]
1039#[derive(Subcommand, Debug, Clone)]
1040pub enum AdminCredentialAction {
1041    /// List credentials
1042    List {
1043        /// Filter by relying party ID
1044        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1045        rp_id: Option<String>,
1046    },
1047    /// Show credential details
1048    Show {
1049        /// Credential reference (hex)
1050        #[arg(value_name = "CREDENTIAL_REF")]
1051        credential_ref: String,
1052    },
1053    /// Revoke a credential
1054    Revoke {
1055        /// Credential reference (hex)
1056        #[arg(value_name = "CREDENTIAL_REF")]
1057        credential_ref: String,
1058        /// Confirm the revocation
1059        #[arg(long)]
1060        confirm: bool,
1061    },
1062    /// Delete a credential
1063    Delete {
1064        /// Credential reference (hex)
1065        #[arg(value_name = "CREDENTIAL_REF")]
1066        credential_ref: String,
1067        /// Confirm the deletion
1068        #[arg(long)]
1069        confirm: bool,
1070    },
1071}
1072
1073/// Admin delegation actions
1074#[cfg(feature = "agent")]
1075#[derive(Subcommand, Debug, Clone)]
1076pub enum AdminDelegationAction {
1077    /// Show delegation details
1078    Show {
1079        /// Grant identifier (hex)
1080        #[arg(value_name = "GRANT_ID")]
1081        grant_id: String,
1082    },
1083    /// List all delegations
1084    List {
1085        /// Filter by profile
1086        #[arg(long, value_name = "PROFILE")]
1087        profile: Option<String>,
1088    },
1089    /// Revoke a delegation
1090    Revoke {
1091        /// Grant identifier (hex)
1092        #[arg(value_name = "GRANT_ID")]
1093        grant_id: String,
1094        /// Confirm the revocation
1095        #[arg(long)]
1096        confirm: bool,
1097    },
1098}
1099
1100/// Admin session actions
1101#[cfg(feature = "agent")]
1102#[derive(Subcommand, Debug, Clone)]
1103pub enum AdminSessionAction {
1104    /// Show session details
1105    Show {
1106        /// Session identifier (hex)
1107        #[arg(value_name = "SESSION_ID")]
1108        session_id: String,
1109    },
1110    /// List all sessions
1111    List {
1112        /// Filter by profile
1113        #[arg(long, value_name = "PROFILE")]
1114        profile: Option<String>,
1115    },
1116    /// Revoke a session
1117    Revoke {
1118        /// Session identifier (hex)
1119        #[arg(value_name = "SESSION_ID")]
1120        session_id: String,
1121        /// Confirm the revocation
1122        #[arg(long)]
1123        confirm: bool,
1124    },
1125}
1126
1127/// Admin audit actions
1128#[cfg(feature = "agent")]
1129#[derive(Subcommand, Debug, Clone)]
1130pub enum AdminAuditAction {
1131    /// Show audit subsystem status
1132    Status,
1133    /// Verify audit log integrity
1134    Verify,
1135    /// Export audit log entries
1136    Export {
1137        /// Export format
1138        #[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
1139        format: AdminAuditExportFormat,
1140    },
1141}
1142
1143/// Audit export format for CLI
1144#[cfg(feature = "agent")]
1145#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1146pub enum AdminAuditExportFormat {
1147    Json,
1148    Csv,
1149}
1150
1151/// Supported coding-agent skill targets
1152#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1153pub enum AgentSkillTarget {
1154    Auto,
1155    Opencode,
1156    Claude,
1157    Pi,
1158}
1159
1160/// Skill installation scope
1161#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1162pub enum AgentSkillScope {
1163    User,
1164    Project,
1165}
1166
1167/// Client actions for FIDO2 authenticator management
1168#[derive(Subcommand, Debug, Clone)]
1169pub enum ClientAction {
1170    /// List all available FIDO2 authenticators/devices
1171    Devices,
1172    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
1173    Info,
1174    /// Reset the authenticator (WARNING: deletes ALL credentials)
1175    Reset {
1176        /// Confirmation flag that must be provided twice for safety
1177        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
1178        confirm: u8,
1179    },
1180    /// List all credentials on the authenticator
1181    List {
1182        /// Filter by relying party ID (domain)
1183        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1184        rp_id: Option<String>,
1185    },
1186    /// Show detailed information about a specific credential
1187    Show {
1188        /// Credential ID in hexadecimal format
1189        #[arg(value_name = "CREDENTIAL_ID")]
1190        credential_id: String,
1191    },
1192    /// Delete a specific credential by ID
1193    Delete {
1194        /// Credential ID in hexadecimal format
1195        #[arg(value_name = "CREDENTIAL_ID")]
1196        credential_id: String,
1197    },
1198    /// Rename a credential (update user name and/or display name)
1199    Rename {
1200        /// Credential ID in hexadecimal format
1201        #[arg(value_name = "CREDENTIAL_ID")]
1202        credential_id: String,
1203        /// New user name (login identifier)
1204        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
1205        user_name: Option<String>,
1206        /// New display name (friendly name)
1207        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
1208        display_name: Option<String>,
1209    },
1210    /// Export one software-backed credential as an opaque OpenPGP bundle.
1211    Backup {
1212        /// Credential ID in hexadecimal format
1213        #[arg(value_name = "CREDENTIAL_ID")]
1214        credential_id: String,
1215        /// OpenPGP recipient key ID, fingerprint, or email
1216        #[arg(long, value_name = "RECIPIENT")]
1217        recipient: String,
1218        /// Destination bundle path
1219        #[arg(long, value_name = "PATH")]
1220        output_file: PathBuf,
1221        /// Explicitly acknowledge that a passkey is being exported
1222        #[arg(long = "yes-i-understand-this-exports-a-passkey")]
1223        confirm: bool,
1224    },
1225    /// Restore an encrypted Passless credential bundle.
1226    Restore {
1227        /// Source bundle path
1228        #[arg(value_name = "PATH")]
1229        input_file: PathBuf,
1230        /// Replace a credential with the same ID
1231        #[arg(long)]
1232        replace: bool,
1233        /// Explicitly acknowledge that a passkey is being restored
1234        #[arg(long = "yes-i-understand-this-restores-a-passkey")]
1235        confirm: bool,
1236    },
1237    /// PIN management commands
1238    Pin {
1239        #[command(subcommand)]
1240        action: PinAction,
1241    },
1242}
1243
1244/// PIN management actions
1245#[derive(Subcommand, Debug, Clone)]
1246pub enum PinAction {
1247    /// Set a new PIN (authenticator must not have a PIN set)
1248    Set {
1249        /// The new PIN (minimum 4 characters)
1250        #[arg(value_name = "PIN")]
1251        pin: String,
1252    },
1253    /// Change the existing PIN
1254    Change {
1255        /// The current PIN
1256        #[arg(value_name = "OLD_PIN")]
1257        old_pin: String,
1258        /// The new PIN (minimum 4 characters)
1259        #[arg(value_name = "NEW_PIN")]
1260        new_pin: String,
1261    },
1262    /// Reset built-in user verification retries without deleting credentials
1263    UvReset,
1264}
1265
1266/// Agent principal and session commands
1267#[cfg(feature = "agent")]
1268#[derive(Subcommand, Debug, Clone)]
1269pub enum AgentCommand {
1270    /// Run health diagnostics
1271    Doctor,
1272    /// Show principal capabilities
1273    Capabilities,
1274    /// Show principal instructions
1275    Instructions,
1276    /// Intent management
1277    Intent {
1278        #[command(subcommand)]
1279        action: AgentIntentAction,
1280    },
1281    /// Delegation management
1282    Delegation {
1283        #[command(subcommand)]
1284        action: AgentDelegationAction,
1285    },
1286    /// Credential queries
1287    Credential {
1288        #[command(subcommand)]
1289        action: AgentCredentialAction,
1290    },
1291    /// Show browser bridge status
1292    BrowserStatus,
1293    /// Show endpoint status
1294    EndpointStatus,
1295    /// Send a CDP command to the managed browser session
1296    ///
1297    /// WARNING: This is the full browser-session authority interface.
1298    /// CDP commands can access cookies, DOM, network state, and session data.
1299    /// Output may contain CDP response data — do not mix with credential/admin output.
1300    BrowserControl {
1301        /// CDP request as JSON (e.g. '{"id":1,"method":"Page.navigate","params":{"url":"https://example.com"}}')
1302        #[arg(long, value_name = "JSON", conflicts_with = "request_file")]
1303        request: Option<String>,
1304        /// Path to file containing CDP request JSON (owner/symlink/size checked)
1305        #[arg(long, value_name = "PATH", conflicts_with = "request")]
1306        request_file: Option<std::path::PathBuf>,
1307        /// Timeout in milliseconds (default: 5000, max: 30000)
1308        #[arg(long, value_name = "MS", default_value = "5000")]
1309        timeout_ms: u32,
1310    },
1311    /// Launch a detached principal session
1312    Run {
1313        /// Profile to launch
1314        #[arg(long, value_name = "PROFILE")]
1315        profile: String,
1316        /// Absolute command path and arguments
1317        #[arg(last = true, required = true)]
1318        command: Vec<std::path::PathBuf>,
1319    },
1320}
1321
1322/// Agent intent actions
1323#[cfg(feature = "agent")]
1324#[derive(Subcommand, Debug, Clone)]
1325pub enum AgentIntentAction {
1326    /// Create a new intent
1327    Create {
1328        /// Action type
1329        #[arg(value_enum)]
1330        action: AgentIntentActionType,
1331        /// Relying party ID
1332        #[arg(long, value_name = "RP_ID")]
1333        rp: String,
1334        /// Credential reference (hex)
1335        #[arg(long, value_name = "CREDENTIAL_REF")]
1336        credential: Option<String>,
1337        /// Reason for the intent
1338        #[arg(long, value_name = "REASON")]
1339        reason: Option<String>,
1340    },
1341    /// Show intent status
1342    Show {
1343        /// Request identifier (hex)
1344        #[arg(value_name = "REQUEST_ID")]
1345        request_id: String,
1346    },
1347    /// Wait for intent to reach terminal state
1348    Wait {
1349        /// Request identifier (hex)
1350        #[arg(value_name = "REQUEST_ID")]
1351        request_id: String,
1352        /// Timeout in seconds
1353        #[arg(long, value_name = "SECONDS")]
1354        timeout: Option<u64>,
1355        /// Poll interval in milliseconds
1356        #[arg(long, value_name = "MS")]
1357        poll_interval: Option<u64>,
1358    },
1359    /// Cancel a pending intent
1360    Cancel {
1361        /// Request identifier (hex)
1362        #[arg(value_name = "REQUEST_ID")]
1363        request_id: String,
1364    },
1365}
1366
1367/// Intent action type for CLI
1368#[cfg(feature = "agent")]
1369#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1370pub enum AgentIntentActionType {
1371    Register,
1372    Authenticate,
1373}
1374
1375/// Agent delegation actions
1376#[cfg(feature = "agent")]
1377#[derive(Subcommand, Debug, Clone)]
1378pub enum AgentDelegationAction {
1379    /// Request a new delegation
1380    Request {
1381        /// Relying party ID
1382        #[arg(long, value_name = "RP_ID")]
1383        rp: String,
1384        /// Credential reference (hex)
1385        #[arg(long, value_name = "CREDENTIAL_REF")]
1386        credential: String,
1387        /// Session TTL in seconds
1388        #[arg(long, value_name = "SECONDS")]
1389        session_ttl: u64,
1390        /// Reason for the delegation
1391        #[arg(long, value_name = "REASON")]
1392        reason: Option<String>,
1393    },
1394    /// Show delegation status
1395    Show {
1396        /// Request identifier (hex)
1397        #[arg(value_name = "REQUEST_ID")]
1398        request_id: String,
1399    },
1400    /// Wait for delegation to reach terminal state
1401    Wait {
1402        /// Request identifier (hex)
1403        #[arg(value_name = "REQUEST_ID")]
1404        request_id: String,
1405        /// Timeout in seconds
1406        #[arg(long, value_name = "SECONDS")]
1407        timeout: Option<u64>,
1408        /// Poll interval in milliseconds
1409        #[arg(long, value_name = "MS")]
1410        poll_interval: Option<u64>,
1411    },
1412    /// Cancel a pending delegation
1413    Cancel {
1414        /// Request identifier (hex)
1415        #[arg(value_name = "REQUEST_ID")]
1416        request_id: String,
1417    },
1418}
1419
1420/// Agent credential actions
1421#[cfg(feature = "agent")]
1422#[derive(Subcommand, Debug, Clone)]
1423pub enum AgentCredentialAction {
1424    /// List credentials for the profile
1425    List,
1426    /// Show credential details
1427    Show {
1428        /// Credential reference (hex)
1429        #[arg(value_name = "CREDENTIAL_REF")]
1430        credential_ref: String,
1431    },
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437
1438    #[cfg(feature = "agent")]
1439    #[test]
1440    fn test_agent_admin_install_defaults() {
1441        let args = Args::try_parse_from(["passless", "agent-admin", "install"]).unwrap();
1442        assert!(matches!(
1443            args.command,
1444            Some(Commands::AgentAdmin {
1445                action: AgentAdminAction::Install {
1446                    target: AgentSkillTarget::Auto,
1447                    scope: AgentSkillScope::User,
1448                    force: false,
1449                },
1450                ..
1451            })
1452        ));
1453    }
1454
1455    #[cfg(feature = "agent")]
1456    #[test]
1457    fn test_agent_admin_install_explicit_options() {
1458        let args = Args::try_parse_from([
1459            "passless",
1460            "agent-admin",
1461            "install",
1462            "claude",
1463            "--scope",
1464            "project",
1465            "--force",
1466        ])
1467        .unwrap();
1468        assert!(matches!(
1469            args.command,
1470            Some(Commands::AgentAdmin {
1471                action: AgentAdminAction::Install {
1472                    target: AgentSkillTarget::Claude,
1473                    scope: AgentSkillScope::Project,
1474                    force: true,
1475                },
1476                ..
1477            })
1478        ));
1479    }
1480
1481    #[test]
1482    fn test_pin_config_default_max_uv_retries() {
1483        let config = PinConfig {
1484            enforcement: PinEnforcement::Optional,
1485            min_length: 4,
1486            max_retries: 8,
1487            max_uv_retries: 8,
1488            auto_lock_timeout: 0,
1489        };
1490        assert_eq!(config.max_uv_retries, 8);
1491    }
1492
1493    #[test]
1494    fn test_pin_config_validate_success() {
1495        let config = PinConfig {
1496            enforcement: PinEnforcement::Optional,
1497            min_length: 4,
1498            max_retries: 8,
1499            max_uv_retries: 8,
1500            auto_lock_timeout: 0,
1501        };
1502        assert!(config.validate().is_ok());
1503    }
1504
1505    #[test]
1506    fn test_pin_config_validate_zero_max_uv_retries() {
1507        let config = PinConfig {
1508            enforcement: PinEnforcement::Optional,
1509            min_length: 4,
1510            max_retries: 8,
1511            max_uv_retries: 0,
1512            auto_lock_timeout: 0,
1513        };
1514        let result = config.validate();
1515        assert!(result.is_err());
1516        assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
1517    }
1518
1519    #[test]
1520    fn test_pin_config_validate_zero_max_retries() {
1521        let config = PinConfig {
1522            enforcement: PinEnforcement::Optional,
1523            min_length: 4,
1524            max_retries: 0,
1525            max_uv_retries: 8,
1526            auto_lock_timeout: 0,
1527        };
1528        let result = config.validate();
1529        assert!(result.is_err());
1530        assert!(result.unwrap_err().to_string().contains("max_retries"));
1531    }
1532
1533    #[test]
1534    fn test_pin_config_validate_invalid_min_length() {
1535        let config = PinConfig {
1536            enforcement: PinEnforcement::Optional,
1537            min_length: 3,
1538            max_retries: 8,
1539            max_uv_retries: 8,
1540            auto_lock_timeout: 0,
1541        };
1542        let result = config.validate();
1543        assert!(result.is_err());
1544        assert!(result.unwrap_err().to_string().contains("min_length"));
1545    }
1546
1547    #[test]
1548    fn test_canonicalize_path_existing() {
1549        let dir = std::env::temp_dir();
1550        let canonical = BackendConfig::canonicalize_path(&dir);
1551        assert!(canonical.is_absolute());
1552        assert!(canonical.exists());
1553    }
1554
1555    #[test]
1556    fn test_canonicalize_path_nonexistent() {
1557        let base = std::env::temp_dir();
1558        let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
1559        let canonical = BackendConfig::canonicalize_path(&nonexistent);
1560        assert!(canonical.is_absolute());
1561        assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
1562    }
1563
1564    #[test]
1565    fn test_canonicalize_path_symlink() {
1566        let dir = tempfile::tempdir().unwrap();
1567        let real = dir.path().join("real");
1568        std::fs::create_dir(&real).unwrap();
1569        let link = dir.path().join("link");
1570        std::os::unix::fs::symlink(&real, &link).unwrap();
1571
1572        let canonical_real = BackendConfig::canonicalize_path(&real);
1573        let canonical_link = BackendConfig::canonicalize_path(&link);
1574        assert_eq!(canonical_real, canonical_link);
1575    }
1576
1577    #[test]
1578    fn test_local_state_path_relative_and_absolute() {
1579        let dir = tempfile::tempdir_in(".").unwrap();
1580        let abs_path = std::fs::canonicalize(dir.path()).unwrap();
1581        let rel_path = dir.path().to_path_buf();
1582
1583        let backend_abs = BackendConfig::Local {
1584            path: abs_path.display().to_string(),
1585        };
1586        let backend_rel = BackendConfig::Local {
1587            path: rel_path.display().to_string(),
1588        };
1589        assert_eq!(backend_abs.state_path(), backend_rel.state_path());
1590    }
1591
1592    #[test]
1593    fn test_pass_state_path_different_subpaths() {
1594        let store = "/tmp/passless_test_store";
1595        let backend_a = BackendConfig::Pass {
1596            store_path: store.to_string(),
1597            path: "fido2".to_string(),
1598            gpg_backend: "gnupg-bin".to_string(),
1599        };
1600        let backend_b = BackendConfig::Pass {
1601            store_path: store.to_string(),
1602            path: "fido2-other".to_string(),
1603            gpg_backend: "gnupg-bin".to_string(),
1604        };
1605        assert_ne!(backend_a.state_path(), backend_b.state_path());
1606    }
1607
1608    #[test]
1609    fn test_different_local_paths_produce_different_identities() {
1610        let backend_a = BackendConfig::Local {
1611            path: "/tmp/passless_a".to_string(),
1612        };
1613        let backend_b = BackendConfig::Local {
1614            path: "/tmp/passless_b".to_string(),
1615        };
1616        assert_ne!(backend_a.state_path(), backend_b.state_path());
1617    }
1618
1619    #[cfg(feature = "agent")]
1620    #[test]
1621    fn test_agent_admin_profile_list() {
1622        let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1623        assert!(matches!(
1624            args.command,
1625            Some(Commands::AgentAdmin {
1626                action: AgentAdminAction::Profile {
1627                    action: AdminProfileAction::List,
1628                },
1629                ..
1630            })
1631        ));
1632    }
1633
1634    #[cfg(feature = "agent")]
1635    #[test]
1636    fn test_agent_admin_credential_delete_without_confirm() {
1637        let args = Args::try_parse_from([
1638            "passless",
1639            "agent-admin",
1640            "credential",
1641            "delete",
1642            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1643        ])
1644        .unwrap();
1645        assert!(matches!(
1646            args.command,
1647            Some(Commands::AgentAdmin {
1648                action: AgentAdminAction::Credential {
1649                    action: AdminCredentialAction::Delete { confirm: false, .. },
1650                },
1651                ..
1652            })
1653        ));
1654    }
1655
1656    #[cfg(feature = "agent")]
1657    #[test]
1658    fn test_agent_admin_credential_delete_with_confirm() {
1659        let args = Args::try_parse_from([
1660            "passless",
1661            "agent-admin",
1662            "credential",
1663            "delete",
1664            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1665            "--confirm",
1666        ])
1667        .unwrap();
1668        assert!(matches!(
1669            args.command,
1670            Some(Commands::AgentAdmin {
1671                action: AgentAdminAction::Credential {
1672                    action: AdminCredentialAction::Delete { confirm: true, .. },
1673                },
1674                ..
1675            })
1676        ));
1677    }
1678
1679    #[cfg(feature = "agent")]
1680    #[test]
1681    fn test_agent_admin_shutdown_hidden() {
1682        let args =
1683            Args::try_parse_from(["passless", "agent-admin", "shutdown", "--confirm"]).unwrap();
1684        assert!(matches!(
1685            args.command,
1686            Some(Commands::AgentAdmin {
1687                action: AgentAdminAction::Shutdown { confirm: true },
1688                ..
1689            })
1690        ));
1691    }
1692
1693    #[cfg(feature = "agent")]
1694    #[test]
1695    fn test_agent_admin_output_default_json() {
1696        let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1697        match args.command {
1698            Some(Commands::AgentAdmin { output, .. }) => {
1699                assert_eq!(output, OutputFormat::Json);
1700            }
1701            _ => panic!("expected AgentAdmin command"),
1702        }
1703    }
1704
1705    #[cfg(feature = "agent")]
1706    #[test]
1707    fn test_agent_admin_output_plain() {
1708        let args = Args::try_parse_from([
1709            "passless",
1710            "agent-admin",
1711            "--output",
1712            "plain",
1713            "profile",
1714            "list",
1715        ])
1716        .unwrap();
1717        match args.command {
1718            Some(Commands::AgentAdmin { output, .. }) => {
1719                assert_eq!(output, OutputFormat::Plain);
1720            }
1721            _ => panic!("expected AgentAdmin command"),
1722        }
1723    }
1724
1725    #[cfg(feature = "agent")]
1726    #[test]
1727    fn test_agent_doctor_parses() {
1728        let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1729        assert!(matches!(
1730            args.command,
1731            Some(Commands::Agent {
1732                action: AgentCommand::Doctor,
1733                ..
1734            })
1735        ));
1736    }
1737
1738    #[cfg(feature = "agent")]
1739    #[test]
1740    fn test_agent_run_with_command() {
1741        let args = Args::try_parse_from([
1742            "passless",
1743            "agent",
1744            "run",
1745            "--profile",
1746            "myprofile",
1747            "--",
1748            "/usr/bin/test",
1749            "arg1",
1750        ])
1751        .unwrap();
1752        match args.command {
1753            Some(Commands::Agent {
1754                action: AgentCommand::Run { profile, command },
1755                ..
1756            }) => {
1757                assert_eq!(profile, "myprofile");
1758                assert_eq!(command.len(), 2);
1759            }
1760            _ => panic!("expected Agent Run command"),
1761        }
1762    }
1763
1764    #[cfg(feature = "agent")]
1765    #[test]
1766    fn test_agent_intent_create_parses() {
1767        let args = Args::try_parse_from([
1768            "passless",
1769            "agent",
1770            "intent",
1771            "create",
1772            "register",
1773            "--rp",
1774            "example.com",
1775        ])
1776        .unwrap();
1777        assert!(matches!(
1778            args.command,
1779            Some(Commands::Agent {
1780                action: AgentCommand::Intent {
1781                    action: AgentIntentAction::Create {
1782                        action: AgentIntentActionType::Register,
1783                        ..
1784                    },
1785                },
1786                ..
1787            })
1788        ));
1789    }
1790
1791    #[cfg(feature = "agent")]
1792    #[test]
1793    fn test_agent_output_default_json() {
1794        let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1795        match args.command {
1796            Some(Commands::Agent { output, .. }) => {
1797                assert_eq!(output, OutputFormat::Json);
1798            }
1799            _ => panic!("expected Agent command"),
1800        }
1801    }
1802
1803    #[cfg(feature = "agent")]
1804    #[test]
1805    fn test_shell_completions_contain_agent_commands() {
1806        use clap::CommandFactory;
1807
1808        let cmd = Args::command();
1809        let mut buf = Vec::new();
1810        clap_complete::generate(
1811            clap_complete::Shell::Bash,
1812            &mut cmd.clone(),
1813            "passless",
1814            &mut buf,
1815        );
1816        let completion = String::from_utf8(buf).unwrap();
1817
1818        for expected in [
1819            "agent-admin",
1820            "agent",
1821            "install",
1822            "browser-control",
1823            "intent",
1824            "delegation",
1825            "doctor",
1826            "capabilities",
1827            "instructions",
1828        ] {
1829            assert!(
1830                completion.contains(expected),
1831                "bash completion missing '{}'",
1832                expected
1833            );
1834        }
1835    }
1836
1837    #[cfg(feature = "agent")]
1838    #[test]
1839    fn test_shell_completions_zsh_contain_agent_commands() {
1840        use clap::CommandFactory;
1841
1842        let cmd = Args::command();
1843        let mut buf = Vec::new();
1844        clap_complete::generate(
1845            clap_complete::Shell::Zsh,
1846            &mut cmd.clone(),
1847            "passless",
1848            &mut buf,
1849        );
1850        let completion = String::from_utf8(buf).unwrap();
1851
1852        for expected in ["agent-admin", "agent", "install", "browser-control"] {
1853            assert!(
1854                completion.contains(expected),
1855                "zsh completion missing '{}'",
1856                expected
1857            );
1858        }
1859    }
1860
1861    #[cfg(feature = "agent")]
1862    #[test]
1863    fn test_config_print_includes_agent_fields() {
1864        let mut default_args = Args::parse_from(["passless"]);
1865        let config = AppConfig::from(&mut default_args.config);
1866        let toml_output = config.to_toml_with_comments();
1867
1868        assert!(
1869            toml_output.contains("backend_type"),
1870            "config print missing backend_type"
1871        );
1872        assert!(
1873            toml_output.contains("[security]"),
1874            "config print missing [security] section"
1875        );
1876        assert!(
1877            toml_output.contains("[pin]"),
1878            "config print missing [pin] section"
1879        );
1880        assert!(
1881            toml_output.contains("always_uv"),
1882            "config print missing always_uv"
1883        );
1884        assert!(
1885            toml_output.contains("notification_timeout"),
1886            "config print missing notification_timeout"
1887        );
1888    }
1889
1890    #[test]
1891    fn test_config_print_contains_passless_header() {
1892        let mut default_args = Args::parse_from(["passless"]);
1893        let config = AppConfig::from(&mut default_args.config);
1894        let toml_output = config.to_toml_with_comments();
1895
1896        assert!(toml_output.contains("Passless Configuration File"));
1897        assert!(toml_output.contains("~/.config/passless/config.toml"));
1898    }
1899
1900    #[test]
1901    fn test_config_print_contains_local_backend_section() {
1902        let mut default_args = Args::parse_from(["passless"]);
1903        let config = AppConfig::from(&mut default_args.config);
1904        let toml_output = config.to_toml_with_comments();
1905
1906        assert!(toml_output.contains("[local]"));
1907        assert!(toml_output.contains("path"));
1908    }
1909
1910    #[test]
1911    fn test_config_print_contains_pass_backend_section() {
1912        let mut default_args = Args::parse_from(["passless"]);
1913        let config = AppConfig::from(&mut default_args.config);
1914        let toml_output = config.to_toml_with_comments();
1915
1916        assert!(toml_output.contains("[pass]"));
1917        assert!(toml_output.contains("store_path"));
1918        assert!(toml_output.contains("gpg_backend"));
1919    }
1920}