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    /// Browser session management
968    Browser {
969        #[command(subcommand)]
970        action: AdminBrowserAction,
971    },
972    /// Audit log management
973    Audit {
974        #[command(subcommand)]
975        action: AdminAuditAction,
976    },
977    /// Shut down the running daemon
978    #[command(hide = true)]
979    Shutdown {
980        /// Confirm the shutdown
981        #[arg(long)]
982        confirm: bool,
983    },
984}
985
986/// Admin profile actions
987#[cfg(feature = "agent")]
988#[derive(Subcommand, Debug, Clone)]
989pub enum AdminProfileAction {
990    /// Check if a profile exists and is valid
991    Check {
992        /// Profile identifier
993        #[arg(value_name = "PROFILE")]
994        profile: String,
995    },
996    /// Show profile details
997    Show {
998        /// Profile identifier
999        #[arg(value_name = "PROFILE")]
1000        profile: String,
1001    },
1002    /// List all configured profiles
1003    List,
1004    /// Enable a profile
1005    Enable {
1006        /// Profile identifier
1007        #[arg(value_name = "PROFILE")]
1008        profile: String,
1009    },
1010    /// Disable a profile
1011    Disable {
1012        /// Profile identifier
1013        #[arg(value_name = "PROFILE")]
1014        profile: String,
1015    },
1016}
1017
1018/// Admin policy actions
1019#[cfg(feature = "agent")]
1020#[derive(Subcommand, Debug, Clone)]
1021pub enum AdminPolicyAction {
1022    /// Check policy validity for a profile
1023    Check {
1024        /// Profile identifier
1025        #[arg(value_name = "PROFILE")]
1026        profile: String,
1027    },
1028    /// Reload policy for a profile
1029    Reload {
1030        /// Profile identifier
1031        #[arg(value_name = "PROFILE")]
1032        profile: String,
1033    },
1034    /// Show current policy for a profile
1035    Show {
1036        /// Profile identifier
1037        #[arg(value_name = "PROFILE")]
1038        profile: String,
1039    },
1040}
1041
1042/// Admin credential actions
1043#[cfg(feature = "agent")]
1044#[derive(Subcommand, Debug, Clone)]
1045pub enum AdminCredentialAction {
1046    /// List credentials
1047    List {
1048        /// Filter by relying party ID
1049        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1050        rp_id: Option<String>,
1051    },
1052    /// Show credential details
1053    Show {
1054        /// Credential reference (hex)
1055        #[arg(value_name = "CREDENTIAL_REF")]
1056        credential_ref: String,
1057    },
1058    /// Revoke a credential
1059    Revoke {
1060        /// Credential reference (hex)
1061        #[arg(value_name = "CREDENTIAL_REF")]
1062        credential_ref: String,
1063        /// Confirm the revocation
1064        #[arg(long)]
1065        confirm: bool,
1066    },
1067    /// Delete a credential
1068    Delete {
1069        /// Credential reference (hex)
1070        #[arg(value_name = "CREDENTIAL_REF")]
1071        credential_ref: String,
1072        /// Confirm the deletion
1073        #[arg(long)]
1074        confirm: bool,
1075    },
1076}
1077
1078/// Admin delegation actions
1079#[cfg(feature = "agent")]
1080#[derive(Subcommand, Debug, Clone)]
1081pub enum AdminDelegationAction {
1082    /// Show delegation details
1083    Show {
1084        /// Grant identifier (hex)
1085        #[arg(value_name = "GRANT_ID")]
1086        grant_id: String,
1087    },
1088    /// List all delegations
1089    List {
1090        /// Filter by profile
1091        #[arg(long, value_name = "PROFILE")]
1092        profile: Option<String>,
1093    },
1094    /// Revoke a delegation
1095    Revoke {
1096        /// Grant identifier (hex)
1097        #[arg(value_name = "GRANT_ID")]
1098        grant_id: String,
1099        /// Confirm the revocation
1100        #[arg(long)]
1101        confirm: bool,
1102    },
1103    /// Create an exact-RP, short-lived, one-shot registration enrollment grant
1104    RequestRegistration {
1105        /// Profile identifier
1106        #[arg(long, value_name = "PROFILE")]
1107        profile: String,
1108        /// Relying party ID
1109        #[arg(long, value_name = "RP_ID")]
1110        rp: String,
1111        /// Enrollment grant TTL in seconds (clamped to profile TTL and 300 seconds)
1112        #[arg(long, value_name = "SECONDS")]
1113        session_ttl: u64,
1114        /// Reason for the registration
1115        #[arg(long, value_name = "REASON")]
1116        reason: Option<String>,
1117    },
1118}
1119
1120/// Admin session actions
1121#[cfg(feature = "agent")]
1122#[derive(Subcommand, Debug, Clone)]
1123pub enum AdminSessionAction {
1124    /// Show session details
1125    Show {
1126        /// Session identifier (hex)
1127        #[arg(value_name = "SESSION_ID")]
1128        session_id: String,
1129    },
1130    /// List all sessions
1131    List {
1132        /// Filter by profile
1133        #[arg(long, value_name = "PROFILE")]
1134        profile: Option<String>,
1135    },
1136    /// Revoke a session
1137    Revoke {
1138        /// Session identifier (hex)
1139        #[arg(value_name = "SESSION_ID")]
1140        session_id: String,
1141        /// Confirm the revocation
1142        #[arg(long)]
1143        confirm: bool,
1144    },
1145}
1146
1147/// Admin browser actions
1148#[cfg(feature = "agent")]
1149#[derive(Subcommand, Debug, Clone)]
1150pub enum AdminBrowserAction {
1151    /// Launch a browser session with the agent extension
1152    Launch {
1153        /// Profile identifier
1154        #[arg(long, value_name = "PROFILE")]
1155        profile: String,
1156        /// Start URL to open in the browser
1157        #[arg(long, value_name = "URL")]
1158        url: Option<String>,
1159    },
1160}
1161
1162/// Admin audit actions
1163#[cfg(feature = "agent")]
1164#[derive(Subcommand, Debug, Clone)]
1165pub enum AdminAuditAction {
1166    /// Show audit subsystem status
1167    Status,
1168    /// Verify audit log integrity
1169    Verify,
1170    /// Export audit log entries
1171    Export {
1172        /// Export format
1173        #[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
1174        format: AdminAuditExportFormat,
1175    },
1176}
1177
1178/// Audit export format for CLI
1179#[cfg(feature = "agent")]
1180#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1181pub enum AdminAuditExportFormat {
1182    Json,
1183    Csv,
1184}
1185
1186/// Supported coding-agent skill targets
1187#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1188pub enum AgentSkillTarget {
1189    Auto,
1190    Opencode,
1191    Claude,
1192    Pi,
1193}
1194
1195/// Skill installation scope
1196#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1197pub enum AgentSkillScope {
1198    User,
1199    Project,
1200}
1201
1202/// Client actions for FIDO2 authenticator management
1203#[derive(Subcommand, Debug, Clone)]
1204pub enum ClientAction {
1205    /// List all available FIDO2 authenticators/devices
1206    Devices,
1207    /// Get authenticator information (capabilities, AAGUID, versions, etc.)
1208    Info,
1209    /// Reset the authenticator (WARNING: deletes ALL credentials)
1210    Reset {
1211        /// Confirmation flag that must be provided twice for safety
1212        #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
1213        confirm: u8,
1214    },
1215    /// List all credentials on the authenticator
1216    List {
1217        /// Filter by relying party ID (domain)
1218        #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1219        rp_id: Option<String>,
1220    },
1221    /// Show detailed information about a specific credential
1222    Show {
1223        /// Credential ID in hexadecimal format
1224        #[arg(value_name = "CREDENTIAL_ID")]
1225        credential_id: String,
1226    },
1227    /// Delete a specific credential by ID
1228    Delete {
1229        /// Credential ID in hexadecimal format
1230        #[arg(value_name = "CREDENTIAL_ID")]
1231        credential_id: String,
1232    },
1233    /// Rename a credential (update user name and/or display name)
1234    Rename {
1235        /// Credential ID in hexadecimal format
1236        #[arg(value_name = "CREDENTIAL_ID")]
1237        credential_id: String,
1238        /// New user name (login identifier)
1239        #[arg(short = 'u', long = "user-name", value_name = "NAME")]
1240        user_name: Option<String>,
1241        /// New display name (friendly name)
1242        #[arg(short = 'n', long = "display-name", value_name = "NAME")]
1243        display_name: Option<String>,
1244    },
1245    /// Export one software-backed credential as an opaque OpenPGP bundle.
1246    Backup {
1247        /// Credential ID in hexadecimal format
1248        #[arg(value_name = "CREDENTIAL_ID")]
1249        credential_id: String,
1250        /// OpenPGP recipient key ID, fingerprint, or email
1251        #[arg(long, value_name = "RECIPIENT")]
1252        recipient: String,
1253        /// Destination bundle path
1254        #[arg(long, value_name = "PATH")]
1255        output_file: PathBuf,
1256        /// Explicitly acknowledge that a passkey is being exported
1257        #[arg(long = "yes-i-understand-this-exports-a-passkey")]
1258        confirm: bool,
1259    },
1260    /// Restore an encrypted Passless credential bundle.
1261    Restore {
1262        /// Source bundle path
1263        #[arg(value_name = "PATH")]
1264        input_file: PathBuf,
1265        /// Replace a credential with the same ID
1266        #[arg(long)]
1267        replace: bool,
1268        /// Explicitly acknowledge that a passkey is being restored
1269        #[arg(long = "yes-i-understand-this-restores-a-passkey")]
1270        confirm: bool,
1271    },
1272    /// PIN management commands
1273    Pin {
1274        #[command(subcommand)]
1275        action: PinAction,
1276    },
1277}
1278
1279/// PIN management actions
1280#[derive(Subcommand, Debug, Clone)]
1281pub enum PinAction {
1282    /// Set a new PIN (authenticator must not have a PIN set)
1283    Set {
1284        /// The new PIN (minimum 4 characters)
1285        #[arg(value_name = "PIN")]
1286        pin: String,
1287    },
1288    /// Change the existing PIN
1289    Change {
1290        /// The current PIN
1291        #[arg(value_name = "OLD_PIN")]
1292        old_pin: String,
1293        /// The new PIN (minimum 4 characters)
1294        #[arg(value_name = "NEW_PIN")]
1295        new_pin: String,
1296    },
1297    /// Reset built-in user verification retries without deleting credentials
1298    UvReset,
1299}
1300
1301/// Agent principal and session commands
1302#[cfg(feature = "agent")]
1303#[derive(Subcommand, Debug, Clone)]
1304pub enum AgentCommand {
1305    /// Run health diagnostics
1306    Doctor,
1307    /// Show principal capabilities
1308    Capabilities,
1309    /// Show principal instructions
1310    Instructions,
1311    /// Intent management
1312    Intent {
1313        #[command(subcommand)]
1314        action: AgentIntentAction,
1315    },
1316    /// Delegation management
1317    Delegation {
1318        #[command(subcommand)]
1319        action: AgentDelegationAction,
1320    },
1321    /// Credential queries
1322    Credential {
1323        #[command(subcommand)]
1324        action: AgentCredentialAction,
1325    },
1326    /// Show browser bridge status
1327    BrowserStatus,
1328    /// Show endpoint status
1329    EndpointStatus,
1330    /// Send a CDP command to the managed browser session
1331    ///
1332    /// WARNING: This is the full browser-session authority interface.
1333    /// CDP commands can access cookies, DOM, network state, and session data.
1334    /// Output may contain CDP response data — do not mix with credential/admin output.
1335    BrowserControl {
1336        /// CDP request as JSON (e.g. '{"id":1,"method":"Page.navigate","params":{"url":"https://example.com"}}')
1337        #[arg(long, value_name = "JSON", conflicts_with = "request_file")]
1338        request: Option<String>,
1339        /// Path to file containing CDP request JSON (owner/symlink/size checked)
1340        #[arg(long, value_name = "PATH", conflicts_with = "request")]
1341        request_file: Option<std::path::PathBuf>,
1342        /// Timeout in milliseconds (default: 5000, max: 30000)
1343        #[arg(long, value_name = "MS", default_value = "5000")]
1344        timeout_ms: u32,
1345    },
1346    /// Launch a detached principal session
1347    Run {
1348        /// Profile to launch
1349        #[arg(long, value_name = "PROFILE")]
1350        profile: String,
1351        /// Absolute command path and arguments
1352        #[arg(last = true, required = true)]
1353        command: Vec<std::path::PathBuf>,
1354    },
1355}
1356
1357/// Agent intent actions
1358#[cfg(feature = "agent")]
1359#[derive(Subcommand, Debug, Clone)]
1360pub enum AgentIntentAction {
1361    /// Create a new intent
1362    Create {
1363        /// Action type
1364        #[arg(value_enum)]
1365        action: AgentIntentActionType,
1366        /// Relying party ID
1367        #[arg(long, value_name = "RP_ID")]
1368        rp: String,
1369        /// Credential reference (hex)
1370        #[arg(long, value_name = "CREDENTIAL_REF")]
1371        credential: Option<String>,
1372        /// Reason for the intent
1373        #[arg(long, value_name = "REASON")]
1374        reason: Option<String>,
1375    },
1376    /// Show intent status
1377    Show {
1378        /// Request identifier (hex)
1379        #[arg(value_name = "REQUEST_ID")]
1380        request_id: String,
1381    },
1382    /// Wait for intent to reach terminal state
1383    Wait {
1384        /// Request identifier (hex)
1385        #[arg(value_name = "REQUEST_ID")]
1386        request_id: String,
1387        /// Timeout in seconds
1388        #[arg(long, value_name = "SECONDS")]
1389        timeout: Option<u64>,
1390        /// Poll interval in milliseconds
1391        #[arg(long, value_name = "MS")]
1392        poll_interval: Option<u64>,
1393    },
1394    /// Cancel a pending intent
1395    Cancel {
1396        /// Request identifier (hex)
1397        #[arg(value_name = "REQUEST_ID")]
1398        request_id: String,
1399    },
1400}
1401
1402/// Intent action type for CLI
1403#[cfg(feature = "agent")]
1404#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1405pub enum AgentIntentActionType {
1406    Register,
1407    Authenticate,
1408}
1409
1410/// Agent delegation actions
1411#[cfg(feature = "agent")]
1412#[derive(Subcommand, Debug, Clone)]
1413pub enum AgentDelegationAction {
1414    /// Request a new delegation
1415    Request {
1416        /// Relying party ID
1417        #[arg(long, value_name = "RP_ID")]
1418        rp: String,
1419        /// Credential reference (hex)
1420        #[arg(long, value_name = "CREDENTIAL_REF")]
1421        credential: String,
1422        /// Session TTL in seconds
1423        #[arg(long, value_name = "SECONDS")]
1424        session_ttl: u64,
1425        /// Reason for the delegation
1426        #[arg(long, value_name = "REASON")]
1427        reason: Option<String>,
1428    },
1429    /// Show delegation status
1430    Show {
1431        /// Request identifier (hex)
1432        #[arg(value_name = "REQUEST_ID")]
1433        request_id: String,
1434    },
1435    /// Wait for delegation to reach terminal state
1436    Wait {
1437        /// Request identifier (hex)
1438        #[arg(value_name = "REQUEST_ID")]
1439        request_id: String,
1440        /// Timeout in seconds
1441        #[arg(long, value_name = "SECONDS")]
1442        timeout: Option<u64>,
1443        /// Poll interval in milliseconds
1444        #[arg(long, value_name = "MS")]
1445        poll_interval: Option<u64>,
1446    },
1447    /// Cancel a pending delegation
1448    Cancel {
1449        /// Request identifier (hex)
1450        #[arg(value_name = "REQUEST_ID")]
1451        request_id: String,
1452    },
1453}
1454
1455/// Agent credential actions
1456#[cfg(feature = "agent")]
1457#[derive(Subcommand, Debug, Clone)]
1458pub enum AgentCredentialAction {
1459    /// List credentials for the profile
1460    List,
1461    /// Show credential details
1462    Show {
1463        /// Credential reference (hex)
1464        #[arg(value_name = "CREDENTIAL_REF")]
1465        credential_ref: String,
1466    },
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    #[cfg(feature = "agent")]
1474    #[test]
1475    fn test_agent_admin_install_defaults() {
1476        let args = Args::try_parse_from(["passless", "agent-admin", "install"]).unwrap();
1477        assert!(matches!(
1478            args.command,
1479            Some(Commands::AgentAdmin {
1480                action: AgentAdminAction::Install {
1481                    target: AgentSkillTarget::Auto,
1482                    scope: AgentSkillScope::User,
1483                    force: false,
1484                },
1485                ..
1486            })
1487        ));
1488    }
1489
1490    #[cfg(feature = "agent")]
1491    #[test]
1492    fn test_agent_admin_install_explicit_options() {
1493        let args = Args::try_parse_from([
1494            "passless",
1495            "agent-admin",
1496            "install",
1497            "claude",
1498            "--scope",
1499            "project",
1500            "--force",
1501        ])
1502        .unwrap();
1503        assert!(matches!(
1504            args.command,
1505            Some(Commands::AgentAdmin {
1506                action: AgentAdminAction::Install {
1507                    target: AgentSkillTarget::Claude,
1508                    scope: AgentSkillScope::Project,
1509                    force: true,
1510                },
1511                ..
1512            })
1513        ));
1514    }
1515
1516    #[test]
1517    fn test_pin_config_default_max_uv_retries() {
1518        let config = PinConfig {
1519            enforcement: PinEnforcement::Optional,
1520            min_length: 4,
1521            max_retries: 8,
1522            max_uv_retries: 8,
1523            auto_lock_timeout: 0,
1524        };
1525        assert_eq!(config.max_uv_retries, 8);
1526    }
1527
1528    #[test]
1529    fn test_pin_config_validate_success() {
1530        let config = PinConfig {
1531            enforcement: PinEnforcement::Optional,
1532            min_length: 4,
1533            max_retries: 8,
1534            max_uv_retries: 8,
1535            auto_lock_timeout: 0,
1536        };
1537        assert!(config.validate().is_ok());
1538    }
1539
1540    #[test]
1541    fn test_pin_config_validate_zero_max_uv_retries() {
1542        let config = PinConfig {
1543            enforcement: PinEnforcement::Optional,
1544            min_length: 4,
1545            max_retries: 8,
1546            max_uv_retries: 0,
1547            auto_lock_timeout: 0,
1548        };
1549        let result = config.validate();
1550        assert!(result.is_err());
1551        assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
1552    }
1553
1554    #[test]
1555    fn test_pin_config_validate_zero_max_retries() {
1556        let config = PinConfig {
1557            enforcement: PinEnforcement::Optional,
1558            min_length: 4,
1559            max_retries: 0,
1560            max_uv_retries: 8,
1561            auto_lock_timeout: 0,
1562        };
1563        let result = config.validate();
1564        assert!(result.is_err());
1565        assert!(result.unwrap_err().to_string().contains("max_retries"));
1566    }
1567
1568    #[test]
1569    fn test_pin_config_validate_invalid_min_length() {
1570        let config = PinConfig {
1571            enforcement: PinEnforcement::Optional,
1572            min_length: 3,
1573            max_retries: 8,
1574            max_uv_retries: 8,
1575            auto_lock_timeout: 0,
1576        };
1577        let result = config.validate();
1578        assert!(result.is_err());
1579        assert!(result.unwrap_err().to_string().contains("min_length"));
1580    }
1581
1582    #[test]
1583    fn test_canonicalize_path_existing() {
1584        let dir = std::env::temp_dir();
1585        let canonical = BackendConfig::canonicalize_path(&dir);
1586        assert!(canonical.is_absolute());
1587        assert!(canonical.exists());
1588    }
1589
1590    #[test]
1591    fn test_canonicalize_path_nonexistent() {
1592        let base = std::env::temp_dir();
1593        let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
1594        let canonical = BackendConfig::canonicalize_path(&nonexistent);
1595        assert!(canonical.is_absolute());
1596        assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
1597    }
1598
1599    #[test]
1600    fn test_canonicalize_path_symlink() {
1601        let dir = tempfile::tempdir().unwrap();
1602        let real = dir.path().join("real");
1603        std::fs::create_dir(&real).unwrap();
1604        let link = dir.path().join("link");
1605        std::os::unix::fs::symlink(&real, &link).unwrap();
1606
1607        let canonical_real = BackendConfig::canonicalize_path(&real);
1608        let canonical_link = BackendConfig::canonicalize_path(&link);
1609        assert_eq!(canonical_real, canonical_link);
1610    }
1611
1612    #[test]
1613    fn test_local_state_path_relative_and_absolute() {
1614        let dir = tempfile::tempdir_in(".").unwrap();
1615        let abs_path = std::fs::canonicalize(dir.path()).unwrap();
1616        let rel_path = dir.path().to_path_buf();
1617
1618        let backend_abs = BackendConfig::Local {
1619            path: abs_path.display().to_string(),
1620        };
1621        let backend_rel = BackendConfig::Local {
1622            path: rel_path.display().to_string(),
1623        };
1624        assert_eq!(backend_abs.state_path(), backend_rel.state_path());
1625    }
1626
1627    #[test]
1628    fn test_pass_state_path_different_subpaths() {
1629        let store = "/tmp/passless_test_store";
1630        let backend_a = BackendConfig::Pass {
1631            store_path: store.to_string(),
1632            path: "fido2".to_string(),
1633            gpg_backend: "gnupg-bin".to_string(),
1634        };
1635        let backend_b = BackendConfig::Pass {
1636            store_path: store.to_string(),
1637            path: "fido2-other".to_string(),
1638            gpg_backend: "gnupg-bin".to_string(),
1639        };
1640        assert_ne!(backend_a.state_path(), backend_b.state_path());
1641    }
1642
1643    #[test]
1644    fn test_different_local_paths_produce_different_identities() {
1645        let backend_a = BackendConfig::Local {
1646            path: "/tmp/passless_a".to_string(),
1647        };
1648        let backend_b = BackendConfig::Local {
1649            path: "/tmp/passless_b".to_string(),
1650        };
1651        assert_ne!(backend_a.state_path(), backend_b.state_path());
1652    }
1653
1654    #[cfg(feature = "agent")]
1655    #[test]
1656    fn test_agent_admin_profile_list() {
1657        let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1658        assert!(matches!(
1659            args.command,
1660            Some(Commands::AgentAdmin {
1661                action: AgentAdminAction::Profile {
1662                    action: AdminProfileAction::List,
1663                },
1664                ..
1665            })
1666        ));
1667    }
1668
1669    #[cfg(feature = "agent")]
1670    #[test]
1671    fn test_agent_admin_credential_delete_without_confirm() {
1672        let args = Args::try_parse_from([
1673            "passless",
1674            "agent-admin",
1675            "credential",
1676            "delete",
1677            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1678        ])
1679        .unwrap();
1680        assert!(matches!(
1681            args.command,
1682            Some(Commands::AgentAdmin {
1683                action: AgentAdminAction::Credential {
1684                    action: AdminCredentialAction::Delete { confirm: false, .. },
1685                },
1686                ..
1687            })
1688        ));
1689    }
1690
1691    #[cfg(feature = "agent")]
1692    #[test]
1693    fn test_agent_admin_credential_delete_with_confirm() {
1694        let args = Args::try_parse_from([
1695            "passless",
1696            "agent-admin",
1697            "credential",
1698            "delete",
1699            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1700            "--confirm",
1701        ])
1702        .unwrap();
1703        assert!(matches!(
1704            args.command,
1705            Some(Commands::AgentAdmin {
1706                action: AgentAdminAction::Credential {
1707                    action: AdminCredentialAction::Delete { confirm: true, .. },
1708                },
1709                ..
1710            })
1711        ));
1712    }
1713
1714    #[cfg(feature = "agent")]
1715    #[test]
1716    fn test_agent_admin_shutdown_hidden() {
1717        let args =
1718            Args::try_parse_from(["passless", "agent-admin", "shutdown", "--confirm"]).unwrap();
1719        assert!(matches!(
1720            args.command,
1721            Some(Commands::AgentAdmin {
1722                action: AgentAdminAction::Shutdown { confirm: true },
1723                ..
1724            })
1725        ));
1726    }
1727
1728    #[cfg(feature = "agent")]
1729    #[test]
1730    fn test_agent_admin_output_default_json() {
1731        let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1732        match args.command {
1733            Some(Commands::AgentAdmin { output, .. }) => {
1734                assert_eq!(output, OutputFormat::Json);
1735            }
1736            _ => panic!("expected AgentAdmin command"),
1737        }
1738    }
1739
1740    #[cfg(feature = "agent")]
1741    #[test]
1742    fn test_agent_admin_output_plain() {
1743        let args = Args::try_parse_from([
1744            "passless",
1745            "agent-admin",
1746            "--output",
1747            "plain",
1748            "profile",
1749            "list",
1750        ])
1751        .unwrap();
1752        match args.command {
1753            Some(Commands::AgentAdmin { output, .. }) => {
1754                assert_eq!(output, OutputFormat::Plain);
1755            }
1756            _ => panic!("expected AgentAdmin command"),
1757        }
1758    }
1759
1760    #[cfg(feature = "agent")]
1761    #[test]
1762    fn test_agent_doctor_parses() {
1763        let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1764        assert!(matches!(
1765            args.command,
1766            Some(Commands::Agent {
1767                action: AgentCommand::Doctor,
1768                ..
1769            })
1770        ));
1771    }
1772
1773    #[cfg(feature = "agent")]
1774    #[test]
1775    fn test_agent_run_with_command() {
1776        let args = Args::try_parse_from([
1777            "passless",
1778            "agent",
1779            "run",
1780            "--profile",
1781            "myprofile",
1782            "--",
1783            "/usr/bin/test",
1784            "arg1",
1785        ])
1786        .unwrap();
1787        match args.command {
1788            Some(Commands::Agent {
1789                action: AgentCommand::Run { profile, command },
1790                ..
1791            }) => {
1792                assert_eq!(profile, "myprofile");
1793                assert_eq!(command.len(), 2);
1794            }
1795            _ => panic!("expected Agent Run command"),
1796        }
1797    }
1798
1799    #[cfg(feature = "agent")]
1800    #[test]
1801    fn test_agent_intent_create_parses() {
1802        let args = Args::try_parse_from([
1803            "passless",
1804            "agent",
1805            "intent",
1806            "create",
1807            "register",
1808            "--rp",
1809            "example.com",
1810        ])
1811        .unwrap();
1812        assert!(matches!(
1813            args.command,
1814            Some(Commands::Agent {
1815                action: AgentCommand::Intent {
1816                    action: AgentIntentAction::Create {
1817                        action: AgentIntentActionType::Register,
1818                        ..
1819                    },
1820                },
1821                ..
1822            })
1823        ));
1824    }
1825
1826    #[cfg(feature = "agent")]
1827    #[test]
1828    fn test_agent_output_default_json() {
1829        let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1830        match args.command {
1831            Some(Commands::Agent { output, .. }) => {
1832                assert_eq!(output, OutputFormat::Json);
1833            }
1834            _ => panic!("expected Agent command"),
1835        }
1836    }
1837
1838    #[cfg(feature = "agent")]
1839    #[test]
1840    fn test_shell_completions_contain_agent_commands() {
1841        use clap::CommandFactory;
1842
1843        let cmd = Args::command();
1844        let mut buf = Vec::new();
1845        clap_complete::generate(
1846            clap_complete::Shell::Bash,
1847            &mut cmd.clone(),
1848            "passless",
1849            &mut buf,
1850        );
1851        let completion = String::from_utf8(buf).unwrap();
1852
1853        for expected in [
1854            "agent-admin",
1855            "agent",
1856            "install",
1857            "browser-control",
1858            "intent",
1859            "delegation",
1860            "doctor",
1861            "capabilities",
1862            "instructions",
1863        ] {
1864            assert!(
1865                completion.contains(expected),
1866                "bash completion missing '{}'",
1867                expected
1868            );
1869        }
1870    }
1871
1872    #[cfg(feature = "agent")]
1873    #[test]
1874    fn test_shell_completions_zsh_contain_agent_commands() {
1875        use clap::CommandFactory;
1876
1877        let cmd = Args::command();
1878        let mut buf = Vec::new();
1879        clap_complete::generate(
1880            clap_complete::Shell::Zsh,
1881            &mut cmd.clone(),
1882            "passless",
1883            &mut buf,
1884        );
1885        let completion = String::from_utf8(buf).unwrap();
1886
1887        for expected in ["agent-admin", "agent", "install", "browser-control"] {
1888            assert!(
1889                completion.contains(expected),
1890                "zsh completion missing '{}'",
1891                expected
1892            );
1893        }
1894    }
1895
1896    #[cfg(feature = "agent")]
1897    #[test]
1898    fn test_config_print_includes_agent_fields() {
1899        let mut default_args = Args::parse_from(["passless"]);
1900        let config = AppConfig::from(&mut default_args.config);
1901        let toml_output = config.to_toml_with_comments();
1902
1903        assert!(
1904            toml_output.contains("backend_type"),
1905            "config print missing backend_type"
1906        );
1907        assert!(
1908            toml_output.contains("[security]"),
1909            "config print missing [security] section"
1910        );
1911        assert!(
1912            toml_output.contains("[pin]"),
1913            "config print missing [pin] section"
1914        );
1915        assert!(
1916            toml_output.contains("always_uv"),
1917            "config print missing always_uv"
1918        );
1919        assert!(
1920            toml_output.contains("notification_timeout"),
1921            "config print missing notification_timeout"
1922        );
1923    }
1924
1925    #[test]
1926    fn test_config_print_contains_passless_header() {
1927        let mut default_args = Args::parse_from(["passless"]);
1928        let config = AppConfig::from(&mut default_args.config);
1929        let toml_output = config.to_toml_with_comments();
1930
1931        assert!(toml_output.contains("Passless Configuration File"));
1932        assert!(toml_output.contains("~/.config/passless/config.toml"));
1933    }
1934
1935    #[test]
1936    fn test_config_print_contains_local_backend_section() {
1937        let mut default_args = Args::parse_from(["passless"]);
1938        let config = AppConfig::from(&mut default_args.config);
1939        let toml_output = config.to_toml_with_comments();
1940
1941        assert!(toml_output.contains("[local]"));
1942        assert!(toml_output.contains("path"));
1943    }
1944
1945    #[test]
1946    fn test_config_print_contains_pass_backend_section() {
1947        let mut default_args = Args::parse_from(["passless"]);
1948        let config = AppConfig::from(&mut default_args.config);
1949        let toml_output = config.to_toml_with_comments();
1950
1951        assert!(toml_output.contains("[pass]"));
1952        assert!(toml_output.contains("store_path"));
1953        assert!(toml_output.contains("gpg_backend"));
1954    }
1955}