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