1use 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
26pub 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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
37#[group(id = "local-backend-config")]
38pub struct LocalBackendConfig {
39 #[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
51pub 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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
61#[group(id = "pass-backend-config")]
62pub struct PassBackendConfig {
63 #[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 #[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 #[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
96pub 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#[cfg(feature = "tpm")]
107#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
108#[group(id = "tpm-backend-config")]
109pub struct TpmBackendConfig {
110 #[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 #[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 #[arg(long = "tpm-portable", env = "PASSLESS_TPM_PORTABLE")]
129 #[serde(default)]
130 #[default(false)]
131 pub portable: bool,
132}
133
134#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
136#[group(id = "security")]
137pub struct SecurityConfig {
138 #[arg(long = "check-mlock", env = "PASSLESS_CHECK_MLOCK")]
140 #[serde(default)]
141 #[default(true)]
142 pub check_mlock: bool,
143
144 #[arg(long = "disable-core-dumps", env = "PASSLESS_DISABLE_CORE_DUMPS")]
146 #[serde(default)]
147 #[default(true)]
148 pub disable_core_dumps: bool,
149
150 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "lowercase")]
235pub enum PinEnforcement {
236 Never,
238 #[default]
240 Optional,
241 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#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
273#[group(id = "pin")]
274pub struct PinConfig {
275 #[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 #[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 #[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 #[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 #[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 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 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 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 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#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
406pub struct AppConfig {
407 #[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 #[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 #[clap_serde]
430 #[serde(default)]
431 #[command(flatten)]
432 pub pass: PassBackendConfig,
433
434 #[cfg(feature = "tpm")]
436 #[clap_serde]
437 #[serde(default)]
438 #[command(flatten)]
439 pub tpm: TpmBackendConfig,
440
441 #[clap_serde]
443 #[serde(default)]
444 #[command(flatten)]
445 pub local: LocalBackendConfig,
446
447 #[clap_serde]
449 #[serde(default)]
450 #[command(flatten)]
451 pub security: SecurityConfig,
452
453 #[clap_serde]
455 #[serde(default)]
456 #[command(flatten)]
457 pub pin: PinConfig,
458
459 #[cfg(feature = "agent")]
461 #[arg(skip)]
462 pub agents: AgentConfig,
463}
464
465#[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 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(¤t) {
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 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 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 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 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 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 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 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
699 self.security.apply_hardening()
700 }
701
702 pub fn security_config(&self) -> SecurityConfig {
704 self.security.clone()
705 }
706
707 pub fn pin_config(&self) -> PinConfig {
709 self.pin.clone()
710 }
711
712 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#[derive(Parser)]
726#[command(author, version, about)]
727pub struct Args {
728 #[arg(short, long, env = "PASSLESS_CONFIG")]
730 pub config_path: Option<PathBuf>,
731
732 #[command(flatten)]
734 pub config: <AppConfig as ClapSerde>::Opt,
735
736 #[command(subcommand)]
738 pub command: Option<Commands>,
739}
740
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
743pub enum OutputFormat {
744 Plain,
746 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#[derive(Subcommand, Debug, Clone)]
776pub enum Commands {
777 Config {
779 #[command(subcommand)]
780 action: ConfigAction,
781 },
782 Client {
788 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
790 device: Option<String>,
791
792 #[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 #[cfg(feature = "agent")]
807 AgentAdmin {
808 #[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 #[cfg(feature = "agent")]
823 Agent {
824 #[arg(long, value_name = "PROFILE", global = true)]
826 profile: Option<String>,
827
828 #[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 #[cfg(feature = "tpm")]
843 Tpm {
844 #[command(subcommand)]
845 action: TpmAction,
846 },
847}
848
849#[cfg(feature = "tpm")]
851#[derive(Subcommand, Debug, Clone)]
852pub enum TpmAction {
853 #[command(group(clap::ArgGroup::new("seed-source").args(["generate", "seed_file", "seed_stdin"])))]
855 Provision {
856 #[arg(long)]
858 generate: bool,
859 #[arg(long = "seed-file", value_name = "PATH")]
861 seed_file: Option<PathBuf>,
862 #[arg(long = "seed-stdin")]
864 seed_stdin: bool,
865 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
867 path: Option<String>,
868 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
870 tcti: Option<String>,
871 },
872 Status {
874 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
876 path: Option<String>,
877 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
879 tcti: Option<String>,
880 },
881 Remove {
883 #[arg(long)]
885 confirm: bool,
886 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
888 path: Option<String>,
889 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
891 tcti: Option<String>,
892 },
893 #[command(group(clap::ArgGroup::new("selection").args(["credential_id", "all"]).required(true)))]
895 Migrate {
896 #[arg(long = "credential-id", value_name = "ID")]
898 credential_id: Option<String>,
899 #[arg(long)]
901 all: bool,
902 #[arg(long)]
904 dry_run: bool,
905 #[arg(long = "backup-dir", value_name = "PATH")]
907 backup_dir: Option<String>,
908 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
910 path: Option<String>,
911 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
913 tcti: Option<String>,
914 },
915}
916
917#[derive(Subcommand, Debug, Clone)]
919pub enum ConfigAction {
920 Print,
922}
923
924#[cfg(feature = "agent")]
926#[derive(Subcommand, Debug, Clone)]
927pub enum AgentAdminAction {
928 Install {
930 #[arg(value_enum, default_value_t = AgentSkillTarget::Auto)]
932 target: AgentSkillTarget,
933
934 #[arg(long, value_enum, default_value_t = AgentSkillScope::User)]
936 scope: AgentSkillScope,
937
938 #[arg(long)]
940 force: bool,
941 },
942 Profile {
944 #[command(subcommand)]
945 action: AdminProfileAction,
946 },
947 Policy {
949 #[command(subcommand)]
950 action: AdminPolicyAction,
951 },
952 Credential {
954 #[command(subcommand)]
955 action: AdminCredentialAction,
956 },
957 Delegation {
959 #[command(subcommand)]
960 action: AdminDelegationAction,
961 },
962 Session {
964 #[command(subcommand)]
965 action: AdminSessionAction,
966 },
967 Browser {
969 #[command(subcommand)]
970 action: AdminBrowserAction,
971 },
972 Audit {
974 #[command(subcommand)]
975 action: AdminAuditAction,
976 },
977 #[command(hide = true)]
979 Shutdown {
980 #[arg(long)]
982 confirm: bool,
983 },
984}
985
986#[cfg(feature = "agent")]
988#[derive(Subcommand, Debug, Clone)]
989pub enum AdminProfileAction {
990 Check {
992 #[arg(value_name = "PROFILE")]
994 profile: String,
995 },
996 Show {
998 #[arg(value_name = "PROFILE")]
1000 profile: String,
1001 },
1002 List,
1004 Enable {
1006 #[arg(value_name = "PROFILE")]
1008 profile: String,
1009 },
1010 Disable {
1012 #[arg(value_name = "PROFILE")]
1014 profile: String,
1015 },
1016}
1017
1018#[cfg(feature = "agent")]
1020#[derive(Subcommand, Debug, Clone)]
1021pub enum AdminPolicyAction {
1022 Check {
1024 #[arg(value_name = "PROFILE")]
1026 profile: String,
1027 },
1028 Reload {
1030 #[arg(value_name = "PROFILE")]
1032 profile: String,
1033 },
1034 Show {
1036 #[arg(value_name = "PROFILE")]
1038 profile: String,
1039 },
1040}
1041
1042#[cfg(feature = "agent")]
1044#[derive(Subcommand, Debug, Clone)]
1045pub enum AdminCredentialAction {
1046 List {
1048 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1050 rp_id: Option<String>,
1051 },
1052 Show {
1054 #[arg(value_name = "CREDENTIAL_REF")]
1056 credential_ref: String,
1057 },
1058 Revoke {
1060 #[arg(value_name = "CREDENTIAL_REF")]
1062 credential_ref: String,
1063 #[arg(long)]
1065 confirm: bool,
1066 },
1067 Delete {
1069 #[arg(value_name = "CREDENTIAL_REF")]
1071 credential_ref: String,
1072 #[arg(long)]
1074 confirm: bool,
1075 },
1076}
1077
1078#[cfg(feature = "agent")]
1080#[derive(Subcommand, Debug, Clone)]
1081pub enum AdminDelegationAction {
1082 Show {
1084 #[arg(value_name = "GRANT_ID")]
1086 grant_id: String,
1087 },
1088 List {
1090 #[arg(long, value_name = "PROFILE")]
1092 profile: Option<String>,
1093 },
1094 Revoke {
1096 #[arg(value_name = "GRANT_ID")]
1098 grant_id: String,
1099 #[arg(long)]
1101 confirm: bool,
1102 },
1103 RequestRegistration {
1105 #[arg(long, value_name = "PROFILE")]
1107 profile: String,
1108 #[arg(long, value_name = "RP_ID")]
1110 rp: String,
1111 #[arg(long, value_name = "SECONDS")]
1113 session_ttl: u64,
1114 #[arg(long, value_name = "REASON")]
1116 reason: Option<String>,
1117 },
1118}
1119
1120#[cfg(feature = "agent")]
1122#[derive(Subcommand, Debug, Clone)]
1123pub enum AdminSessionAction {
1124 Show {
1126 #[arg(value_name = "SESSION_ID")]
1128 session_id: String,
1129 },
1130 List {
1132 #[arg(long, value_name = "PROFILE")]
1134 profile: Option<String>,
1135 },
1136 Revoke {
1138 #[arg(value_name = "SESSION_ID")]
1140 session_id: String,
1141 #[arg(long)]
1143 confirm: bool,
1144 },
1145}
1146
1147#[cfg(feature = "agent")]
1149#[derive(Subcommand, Debug, Clone)]
1150pub enum AdminBrowserAction {
1151 Launch {
1153 #[arg(long, value_name = "PROFILE")]
1155 profile: String,
1156 #[arg(long, value_name = "URL")]
1158 url: Option<String>,
1159 },
1160}
1161
1162#[cfg(feature = "agent")]
1164#[derive(Subcommand, Debug, Clone)]
1165pub enum AdminAuditAction {
1166 Status,
1168 Verify,
1170 Export {
1172 #[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
1174 format: AdminAuditExportFormat,
1175 },
1176}
1177
1178#[cfg(feature = "agent")]
1180#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1181pub enum AdminAuditExportFormat {
1182 Json,
1183 Csv,
1184}
1185
1186#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1188pub enum AgentSkillTarget {
1189 Auto,
1190 Opencode,
1191 Claude,
1192 Pi,
1193}
1194
1195#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1197pub enum AgentSkillScope {
1198 User,
1199 Project,
1200}
1201
1202#[derive(Subcommand, Debug, Clone)]
1204pub enum ClientAction {
1205 Devices,
1207 Info,
1209 Reset {
1211 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
1213 confirm: u8,
1214 },
1215 List {
1217 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1219 rp_id: Option<String>,
1220 },
1221 Show {
1223 #[arg(value_name = "CREDENTIAL_ID")]
1225 credential_id: String,
1226 },
1227 Delete {
1229 #[arg(value_name = "CREDENTIAL_ID")]
1231 credential_id: String,
1232 },
1233 Rename {
1235 #[arg(value_name = "CREDENTIAL_ID")]
1237 credential_id: String,
1238 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
1240 user_name: Option<String>,
1241 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
1243 display_name: Option<String>,
1244 },
1245 Backup {
1247 #[arg(value_name = "CREDENTIAL_ID")]
1249 credential_id: String,
1250 #[arg(long, value_name = "RECIPIENT")]
1252 recipient: String,
1253 #[arg(long, value_name = "PATH")]
1255 output_file: PathBuf,
1256 #[arg(long = "yes-i-understand-this-exports-a-passkey")]
1258 confirm: bool,
1259 },
1260 Restore {
1262 #[arg(value_name = "PATH")]
1264 input_file: PathBuf,
1265 #[arg(long)]
1267 replace: bool,
1268 #[arg(long = "yes-i-understand-this-restores-a-passkey")]
1270 confirm: bool,
1271 },
1272 Pin {
1274 #[command(subcommand)]
1275 action: PinAction,
1276 },
1277}
1278
1279#[derive(Subcommand, Debug, Clone)]
1281pub enum PinAction {
1282 Set {
1284 #[arg(value_name = "PIN")]
1286 pin: String,
1287 },
1288 Change {
1290 #[arg(value_name = "OLD_PIN")]
1292 old_pin: String,
1293 #[arg(value_name = "NEW_PIN")]
1295 new_pin: String,
1296 },
1297 UvReset,
1299}
1300
1301#[cfg(feature = "agent")]
1303#[derive(Subcommand, Debug, Clone)]
1304pub enum AgentCommand {
1305 Doctor,
1307 Capabilities,
1309 Instructions,
1311 Intent {
1313 #[command(subcommand)]
1314 action: AgentIntentAction,
1315 },
1316 Delegation {
1318 #[command(subcommand)]
1319 action: AgentDelegationAction,
1320 },
1321 Credential {
1323 #[command(subcommand)]
1324 action: AgentCredentialAction,
1325 },
1326 BrowserStatus,
1328 EndpointStatus,
1330 BrowserControl {
1336 #[arg(long, value_name = "JSON", conflicts_with = "request_file")]
1338 request: Option<String>,
1339 #[arg(long, value_name = "PATH", conflicts_with = "request")]
1341 request_file: Option<std::path::PathBuf>,
1342 #[arg(long, value_name = "MS", default_value = "5000")]
1344 timeout_ms: u32,
1345 },
1346 Run {
1348 #[arg(long, value_name = "PROFILE")]
1350 profile: String,
1351 #[arg(last = true, required = true)]
1353 command: Vec<std::path::PathBuf>,
1354 },
1355}
1356
1357#[cfg(feature = "agent")]
1359#[derive(Subcommand, Debug, Clone)]
1360pub enum AgentIntentAction {
1361 Create {
1363 #[arg(value_enum)]
1365 action: AgentIntentActionType,
1366 #[arg(long, value_name = "RP_ID")]
1368 rp: String,
1369 #[arg(long, value_name = "CREDENTIAL_REF")]
1371 credential: Option<String>,
1372 #[arg(long, value_name = "REASON")]
1374 reason: Option<String>,
1375 },
1376 Show {
1378 #[arg(value_name = "REQUEST_ID")]
1380 request_id: String,
1381 },
1382 Wait {
1384 #[arg(value_name = "REQUEST_ID")]
1386 request_id: String,
1387 #[arg(long, value_name = "SECONDS")]
1389 timeout: Option<u64>,
1390 #[arg(long, value_name = "MS")]
1392 poll_interval: Option<u64>,
1393 },
1394 Cancel {
1396 #[arg(value_name = "REQUEST_ID")]
1398 request_id: String,
1399 },
1400}
1401
1402#[cfg(feature = "agent")]
1404#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1405pub enum AgentIntentActionType {
1406 Register,
1407 Authenticate,
1408}
1409
1410#[cfg(feature = "agent")]
1412#[derive(Subcommand, Debug, Clone)]
1413pub enum AgentDelegationAction {
1414 Request {
1416 #[arg(long, value_name = "RP_ID")]
1418 rp: String,
1419 #[arg(long, value_name = "CREDENTIAL_REF")]
1421 credential: String,
1422 #[arg(long, value_name = "SECONDS")]
1424 session_ttl: u64,
1425 #[arg(long, value_name = "REASON")]
1427 reason: Option<String>,
1428 },
1429 Show {
1431 #[arg(value_name = "REQUEST_ID")]
1433 request_id: String,
1434 },
1435 Wait {
1437 #[arg(value_name = "REQUEST_ID")]
1439 request_id: String,
1440 #[arg(long, value_name = "SECONDS")]
1442 timeout: Option<u64>,
1443 #[arg(long, value_name = "MS")]
1445 poll_interval: Option<u64>,
1446 },
1447 Cancel {
1449 #[arg(value_name = "REQUEST_ID")]
1451 request_id: String,
1452 },
1453}
1454
1455#[cfg(feature = "agent")]
1457#[derive(Subcommand, Debug, Clone)]
1458pub enum AgentCredentialAction {
1459 List,
1461 Show {
1463 #[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}