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