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 Audit {
969 #[command(subcommand)]
970 action: AdminAuditAction,
971 },
972 #[command(hide = true)]
974 Shutdown {
975 #[arg(long)]
977 confirm: bool,
978 },
979}
980
981#[cfg(feature = "agent")]
983#[derive(Subcommand, Debug, Clone)]
984pub enum AdminProfileAction {
985 Check {
987 #[arg(value_name = "PROFILE")]
989 profile: String,
990 },
991 Show {
993 #[arg(value_name = "PROFILE")]
995 profile: String,
996 },
997 List,
999 Enable {
1001 #[arg(value_name = "PROFILE")]
1003 profile: String,
1004 },
1005 Disable {
1007 #[arg(value_name = "PROFILE")]
1009 profile: String,
1010 },
1011}
1012
1013#[cfg(feature = "agent")]
1015#[derive(Subcommand, Debug, Clone)]
1016pub enum AdminPolicyAction {
1017 Check {
1019 #[arg(value_name = "PROFILE")]
1021 profile: String,
1022 },
1023 Reload {
1025 #[arg(value_name = "PROFILE")]
1027 profile: String,
1028 },
1029 Show {
1031 #[arg(value_name = "PROFILE")]
1033 profile: String,
1034 },
1035}
1036
1037#[cfg(feature = "agent")]
1039#[derive(Subcommand, Debug, Clone)]
1040pub enum AdminCredentialAction {
1041 List {
1043 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1045 rp_id: Option<String>,
1046 },
1047 Show {
1049 #[arg(value_name = "CREDENTIAL_REF")]
1051 credential_ref: String,
1052 },
1053 Revoke {
1055 #[arg(value_name = "CREDENTIAL_REF")]
1057 credential_ref: String,
1058 #[arg(long)]
1060 confirm: bool,
1061 },
1062 Delete {
1064 #[arg(value_name = "CREDENTIAL_REF")]
1066 credential_ref: String,
1067 #[arg(long)]
1069 confirm: bool,
1070 },
1071}
1072
1073#[cfg(feature = "agent")]
1075#[derive(Subcommand, Debug, Clone)]
1076pub enum AdminDelegationAction {
1077 Show {
1079 #[arg(value_name = "GRANT_ID")]
1081 grant_id: String,
1082 },
1083 List {
1085 #[arg(long, value_name = "PROFILE")]
1087 profile: Option<String>,
1088 },
1089 Revoke {
1091 #[arg(value_name = "GRANT_ID")]
1093 grant_id: String,
1094 #[arg(long)]
1096 confirm: bool,
1097 },
1098}
1099
1100#[cfg(feature = "agent")]
1102#[derive(Subcommand, Debug, Clone)]
1103pub enum AdminSessionAction {
1104 Show {
1106 #[arg(value_name = "SESSION_ID")]
1108 session_id: String,
1109 },
1110 List {
1112 #[arg(long, value_name = "PROFILE")]
1114 profile: Option<String>,
1115 },
1116 Revoke {
1118 #[arg(value_name = "SESSION_ID")]
1120 session_id: String,
1121 #[arg(long)]
1123 confirm: bool,
1124 },
1125}
1126
1127#[cfg(feature = "agent")]
1129#[derive(Subcommand, Debug, Clone)]
1130pub enum AdminAuditAction {
1131 Status,
1133 Verify,
1135 Export {
1137 #[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
1139 format: AdminAuditExportFormat,
1140 },
1141}
1142
1143#[cfg(feature = "agent")]
1145#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1146pub enum AdminAuditExportFormat {
1147 Json,
1148 Csv,
1149}
1150
1151#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1153pub enum AgentSkillTarget {
1154 Auto,
1155 Opencode,
1156 Claude,
1157 Pi,
1158}
1159
1160#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1162pub enum AgentSkillScope {
1163 User,
1164 Project,
1165}
1166
1167#[derive(Subcommand, Debug, Clone)]
1169pub enum ClientAction {
1170 Devices,
1172 Info,
1174 Reset {
1176 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
1178 confirm: u8,
1179 },
1180 List {
1182 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1184 rp_id: Option<String>,
1185 },
1186 Show {
1188 #[arg(value_name = "CREDENTIAL_ID")]
1190 credential_id: String,
1191 },
1192 Delete {
1194 #[arg(value_name = "CREDENTIAL_ID")]
1196 credential_id: String,
1197 },
1198 Rename {
1200 #[arg(value_name = "CREDENTIAL_ID")]
1202 credential_id: String,
1203 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
1205 user_name: Option<String>,
1206 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
1208 display_name: Option<String>,
1209 },
1210 Backup {
1212 #[arg(value_name = "CREDENTIAL_ID")]
1214 credential_id: String,
1215 #[arg(long, value_name = "RECIPIENT")]
1217 recipient: String,
1218 #[arg(long, value_name = "PATH")]
1220 output_file: PathBuf,
1221 #[arg(long = "yes-i-understand-this-exports-a-passkey")]
1223 confirm: bool,
1224 },
1225 Restore {
1227 #[arg(value_name = "PATH")]
1229 input_file: PathBuf,
1230 #[arg(long)]
1232 replace: bool,
1233 #[arg(long = "yes-i-understand-this-restores-a-passkey")]
1235 confirm: bool,
1236 },
1237 Pin {
1239 #[command(subcommand)]
1240 action: PinAction,
1241 },
1242}
1243
1244#[derive(Subcommand, Debug, Clone)]
1246pub enum PinAction {
1247 Set {
1249 #[arg(value_name = "PIN")]
1251 pin: String,
1252 },
1253 Change {
1255 #[arg(value_name = "OLD_PIN")]
1257 old_pin: String,
1258 #[arg(value_name = "NEW_PIN")]
1260 new_pin: String,
1261 },
1262 UvReset,
1264}
1265
1266#[cfg(feature = "agent")]
1268#[derive(Subcommand, Debug, Clone)]
1269pub enum AgentCommand {
1270 Doctor,
1272 Capabilities,
1274 Instructions,
1276 Intent {
1278 #[command(subcommand)]
1279 action: AgentIntentAction,
1280 },
1281 Delegation {
1283 #[command(subcommand)]
1284 action: AgentDelegationAction,
1285 },
1286 Credential {
1288 #[command(subcommand)]
1289 action: AgentCredentialAction,
1290 },
1291 BrowserStatus,
1293 EndpointStatus,
1295 BrowserControl {
1301 #[arg(long, value_name = "JSON", conflicts_with = "request_file")]
1303 request: Option<String>,
1304 #[arg(long, value_name = "PATH", conflicts_with = "request")]
1306 request_file: Option<std::path::PathBuf>,
1307 #[arg(long, value_name = "MS", default_value = "5000")]
1309 timeout_ms: u32,
1310 },
1311 Run {
1313 #[arg(long, value_name = "PROFILE")]
1315 profile: String,
1316 #[arg(last = true, required = true)]
1318 command: Vec<std::path::PathBuf>,
1319 },
1320}
1321
1322#[cfg(feature = "agent")]
1324#[derive(Subcommand, Debug, Clone)]
1325pub enum AgentIntentAction {
1326 Create {
1328 #[arg(value_enum)]
1330 action: AgentIntentActionType,
1331 #[arg(long, value_name = "RP_ID")]
1333 rp: String,
1334 #[arg(long, value_name = "CREDENTIAL_REF")]
1336 credential: Option<String>,
1337 #[arg(long, value_name = "REASON")]
1339 reason: Option<String>,
1340 },
1341 Show {
1343 #[arg(value_name = "REQUEST_ID")]
1345 request_id: String,
1346 },
1347 Wait {
1349 #[arg(value_name = "REQUEST_ID")]
1351 request_id: String,
1352 #[arg(long, value_name = "SECONDS")]
1354 timeout: Option<u64>,
1355 #[arg(long, value_name = "MS")]
1357 poll_interval: Option<u64>,
1358 },
1359 Cancel {
1361 #[arg(value_name = "REQUEST_ID")]
1363 request_id: String,
1364 },
1365}
1366
1367#[cfg(feature = "agent")]
1369#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1370pub enum AgentIntentActionType {
1371 Register,
1372 Authenticate,
1373}
1374
1375#[cfg(feature = "agent")]
1377#[derive(Subcommand, Debug, Clone)]
1378pub enum AgentDelegationAction {
1379 Request {
1381 #[arg(long, value_name = "RP_ID")]
1383 rp: String,
1384 #[arg(long, value_name = "CREDENTIAL_REF")]
1386 credential: String,
1387 #[arg(long, value_name = "SECONDS")]
1389 session_ttl: u64,
1390 #[arg(long, value_name = "REASON")]
1392 reason: Option<String>,
1393 },
1394 Show {
1396 #[arg(value_name = "REQUEST_ID")]
1398 request_id: String,
1399 },
1400 Wait {
1402 #[arg(value_name = "REQUEST_ID")]
1404 request_id: String,
1405 #[arg(long, value_name = "SECONDS")]
1407 timeout: Option<u64>,
1408 #[arg(long, value_name = "MS")]
1410 poll_interval: Option<u64>,
1411 },
1412 Cancel {
1414 #[arg(value_name = "REQUEST_ID")]
1416 request_id: String,
1417 },
1418}
1419
1420#[cfg(feature = "agent")]
1422#[derive(Subcommand, Debug, Clone)]
1423pub enum AgentCredentialAction {
1424 List,
1426 Show {
1428 #[arg(value_name = "CREDENTIAL_REF")]
1430 credential_ref: String,
1431 },
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use super::*;
1437
1438 #[cfg(feature = "agent")]
1439 #[test]
1440 fn test_agent_admin_install_defaults() {
1441 let args = Args::try_parse_from(["passless", "agent-admin", "install"]).unwrap();
1442 assert!(matches!(
1443 args.command,
1444 Some(Commands::AgentAdmin {
1445 action: AgentAdminAction::Install {
1446 target: AgentSkillTarget::Auto,
1447 scope: AgentSkillScope::User,
1448 force: false,
1449 },
1450 ..
1451 })
1452 ));
1453 }
1454
1455 #[cfg(feature = "agent")]
1456 #[test]
1457 fn test_agent_admin_install_explicit_options() {
1458 let args = Args::try_parse_from([
1459 "passless",
1460 "agent-admin",
1461 "install",
1462 "claude",
1463 "--scope",
1464 "project",
1465 "--force",
1466 ])
1467 .unwrap();
1468 assert!(matches!(
1469 args.command,
1470 Some(Commands::AgentAdmin {
1471 action: AgentAdminAction::Install {
1472 target: AgentSkillTarget::Claude,
1473 scope: AgentSkillScope::Project,
1474 force: true,
1475 },
1476 ..
1477 })
1478 ));
1479 }
1480
1481 #[test]
1482 fn test_pin_config_default_max_uv_retries() {
1483 let config = PinConfig {
1484 enforcement: PinEnforcement::Optional,
1485 min_length: 4,
1486 max_retries: 8,
1487 max_uv_retries: 8,
1488 auto_lock_timeout: 0,
1489 };
1490 assert_eq!(config.max_uv_retries, 8);
1491 }
1492
1493 #[test]
1494 fn test_pin_config_validate_success() {
1495 let config = PinConfig {
1496 enforcement: PinEnforcement::Optional,
1497 min_length: 4,
1498 max_retries: 8,
1499 max_uv_retries: 8,
1500 auto_lock_timeout: 0,
1501 };
1502 assert!(config.validate().is_ok());
1503 }
1504
1505 #[test]
1506 fn test_pin_config_validate_zero_max_uv_retries() {
1507 let config = PinConfig {
1508 enforcement: PinEnforcement::Optional,
1509 min_length: 4,
1510 max_retries: 8,
1511 max_uv_retries: 0,
1512 auto_lock_timeout: 0,
1513 };
1514 let result = config.validate();
1515 assert!(result.is_err());
1516 assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
1517 }
1518
1519 #[test]
1520 fn test_pin_config_validate_zero_max_retries() {
1521 let config = PinConfig {
1522 enforcement: PinEnforcement::Optional,
1523 min_length: 4,
1524 max_retries: 0,
1525 max_uv_retries: 8,
1526 auto_lock_timeout: 0,
1527 };
1528 let result = config.validate();
1529 assert!(result.is_err());
1530 assert!(result.unwrap_err().to_string().contains("max_retries"));
1531 }
1532
1533 #[test]
1534 fn test_pin_config_validate_invalid_min_length() {
1535 let config = PinConfig {
1536 enforcement: PinEnforcement::Optional,
1537 min_length: 3,
1538 max_retries: 8,
1539 max_uv_retries: 8,
1540 auto_lock_timeout: 0,
1541 };
1542 let result = config.validate();
1543 assert!(result.is_err());
1544 assert!(result.unwrap_err().to_string().contains("min_length"));
1545 }
1546
1547 #[test]
1548 fn test_canonicalize_path_existing() {
1549 let dir = std::env::temp_dir();
1550 let canonical = BackendConfig::canonicalize_path(&dir);
1551 assert!(canonical.is_absolute());
1552 assert!(canonical.exists());
1553 }
1554
1555 #[test]
1556 fn test_canonicalize_path_nonexistent() {
1557 let base = std::env::temp_dir();
1558 let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
1559 let canonical = BackendConfig::canonicalize_path(&nonexistent);
1560 assert!(canonical.is_absolute());
1561 assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
1562 }
1563
1564 #[test]
1565 fn test_canonicalize_path_symlink() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let real = dir.path().join("real");
1568 std::fs::create_dir(&real).unwrap();
1569 let link = dir.path().join("link");
1570 std::os::unix::fs::symlink(&real, &link).unwrap();
1571
1572 let canonical_real = BackendConfig::canonicalize_path(&real);
1573 let canonical_link = BackendConfig::canonicalize_path(&link);
1574 assert_eq!(canonical_real, canonical_link);
1575 }
1576
1577 #[test]
1578 fn test_local_state_path_relative_and_absolute() {
1579 let dir = tempfile::tempdir_in(".").unwrap();
1580 let abs_path = std::fs::canonicalize(dir.path()).unwrap();
1581 let rel_path = dir.path().to_path_buf();
1582
1583 let backend_abs = BackendConfig::Local {
1584 path: abs_path.display().to_string(),
1585 };
1586 let backend_rel = BackendConfig::Local {
1587 path: rel_path.display().to_string(),
1588 };
1589 assert_eq!(backend_abs.state_path(), backend_rel.state_path());
1590 }
1591
1592 #[test]
1593 fn test_pass_state_path_different_subpaths() {
1594 let store = "/tmp/passless_test_store";
1595 let backend_a = BackendConfig::Pass {
1596 store_path: store.to_string(),
1597 path: "fido2".to_string(),
1598 gpg_backend: "gnupg-bin".to_string(),
1599 };
1600 let backend_b = BackendConfig::Pass {
1601 store_path: store.to_string(),
1602 path: "fido2-other".to_string(),
1603 gpg_backend: "gnupg-bin".to_string(),
1604 };
1605 assert_ne!(backend_a.state_path(), backend_b.state_path());
1606 }
1607
1608 #[test]
1609 fn test_different_local_paths_produce_different_identities() {
1610 let backend_a = BackendConfig::Local {
1611 path: "/tmp/passless_a".to_string(),
1612 };
1613 let backend_b = BackendConfig::Local {
1614 path: "/tmp/passless_b".to_string(),
1615 };
1616 assert_ne!(backend_a.state_path(), backend_b.state_path());
1617 }
1618
1619 #[cfg(feature = "agent")]
1620 #[test]
1621 fn test_agent_admin_profile_list() {
1622 let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1623 assert!(matches!(
1624 args.command,
1625 Some(Commands::AgentAdmin {
1626 action: AgentAdminAction::Profile {
1627 action: AdminProfileAction::List,
1628 },
1629 ..
1630 })
1631 ));
1632 }
1633
1634 #[cfg(feature = "agent")]
1635 #[test]
1636 fn test_agent_admin_credential_delete_without_confirm() {
1637 let args = Args::try_parse_from([
1638 "passless",
1639 "agent-admin",
1640 "credential",
1641 "delete",
1642 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1643 ])
1644 .unwrap();
1645 assert!(matches!(
1646 args.command,
1647 Some(Commands::AgentAdmin {
1648 action: AgentAdminAction::Credential {
1649 action: AdminCredentialAction::Delete { confirm: false, .. },
1650 },
1651 ..
1652 })
1653 ));
1654 }
1655
1656 #[cfg(feature = "agent")]
1657 #[test]
1658 fn test_agent_admin_credential_delete_with_confirm() {
1659 let args = Args::try_parse_from([
1660 "passless",
1661 "agent-admin",
1662 "credential",
1663 "delete",
1664 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1665 "--confirm",
1666 ])
1667 .unwrap();
1668 assert!(matches!(
1669 args.command,
1670 Some(Commands::AgentAdmin {
1671 action: AgentAdminAction::Credential {
1672 action: AdminCredentialAction::Delete { confirm: true, .. },
1673 },
1674 ..
1675 })
1676 ));
1677 }
1678
1679 #[cfg(feature = "agent")]
1680 #[test]
1681 fn test_agent_admin_shutdown_hidden() {
1682 let args =
1683 Args::try_parse_from(["passless", "agent-admin", "shutdown", "--confirm"]).unwrap();
1684 assert!(matches!(
1685 args.command,
1686 Some(Commands::AgentAdmin {
1687 action: AgentAdminAction::Shutdown { confirm: true },
1688 ..
1689 })
1690 ));
1691 }
1692
1693 #[cfg(feature = "agent")]
1694 #[test]
1695 fn test_agent_admin_output_default_json() {
1696 let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1697 match args.command {
1698 Some(Commands::AgentAdmin { output, .. }) => {
1699 assert_eq!(output, OutputFormat::Json);
1700 }
1701 _ => panic!("expected AgentAdmin command"),
1702 }
1703 }
1704
1705 #[cfg(feature = "agent")]
1706 #[test]
1707 fn test_agent_admin_output_plain() {
1708 let args = Args::try_parse_from([
1709 "passless",
1710 "agent-admin",
1711 "--output",
1712 "plain",
1713 "profile",
1714 "list",
1715 ])
1716 .unwrap();
1717 match args.command {
1718 Some(Commands::AgentAdmin { output, .. }) => {
1719 assert_eq!(output, OutputFormat::Plain);
1720 }
1721 _ => panic!("expected AgentAdmin command"),
1722 }
1723 }
1724
1725 #[cfg(feature = "agent")]
1726 #[test]
1727 fn test_agent_doctor_parses() {
1728 let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1729 assert!(matches!(
1730 args.command,
1731 Some(Commands::Agent {
1732 action: AgentCommand::Doctor,
1733 ..
1734 })
1735 ));
1736 }
1737
1738 #[cfg(feature = "agent")]
1739 #[test]
1740 fn test_agent_run_with_command() {
1741 let args = Args::try_parse_from([
1742 "passless",
1743 "agent",
1744 "run",
1745 "--profile",
1746 "myprofile",
1747 "--",
1748 "/usr/bin/test",
1749 "arg1",
1750 ])
1751 .unwrap();
1752 match args.command {
1753 Some(Commands::Agent {
1754 action: AgentCommand::Run { profile, command },
1755 ..
1756 }) => {
1757 assert_eq!(profile, "myprofile");
1758 assert_eq!(command.len(), 2);
1759 }
1760 _ => panic!("expected Agent Run command"),
1761 }
1762 }
1763
1764 #[cfg(feature = "agent")]
1765 #[test]
1766 fn test_agent_intent_create_parses() {
1767 let args = Args::try_parse_from([
1768 "passless",
1769 "agent",
1770 "intent",
1771 "create",
1772 "register",
1773 "--rp",
1774 "example.com",
1775 ])
1776 .unwrap();
1777 assert!(matches!(
1778 args.command,
1779 Some(Commands::Agent {
1780 action: AgentCommand::Intent {
1781 action: AgentIntentAction::Create {
1782 action: AgentIntentActionType::Register,
1783 ..
1784 },
1785 },
1786 ..
1787 })
1788 ));
1789 }
1790
1791 #[cfg(feature = "agent")]
1792 #[test]
1793 fn test_agent_output_default_json() {
1794 let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1795 match args.command {
1796 Some(Commands::Agent { output, .. }) => {
1797 assert_eq!(output, OutputFormat::Json);
1798 }
1799 _ => panic!("expected Agent command"),
1800 }
1801 }
1802
1803 #[cfg(feature = "agent")]
1804 #[test]
1805 fn test_shell_completions_contain_agent_commands() {
1806 use clap::CommandFactory;
1807
1808 let cmd = Args::command();
1809 let mut buf = Vec::new();
1810 clap_complete::generate(
1811 clap_complete::Shell::Bash,
1812 &mut cmd.clone(),
1813 "passless",
1814 &mut buf,
1815 );
1816 let completion = String::from_utf8(buf).unwrap();
1817
1818 for expected in [
1819 "agent-admin",
1820 "agent",
1821 "install",
1822 "browser-control",
1823 "intent",
1824 "delegation",
1825 "doctor",
1826 "capabilities",
1827 "instructions",
1828 ] {
1829 assert!(
1830 completion.contains(expected),
1831 "bash completion missing '{}'",
1832 expected
1833 );
1834 }
1835 }
1836
1837 #[cfg(feature = "agent")]
1838 #[test]
1839 fn test_shell_completions_zsh_contain_agent_commands() {
1840 use clap::CommandFactory;
1841
1842 let cmd = Args::command();
1843 let mut buf = Vec::new();
1844 clap_complete::generate(
1845 clap_complete::Shell::Zsh,
1846 &mut cmd.clone(),
1847 "passless",
1848 &mut buf,
1849 );
1850 let completion = String::from_utf8(buf).unwrap();
1851
1852 for expected in ["agent-admin", "agent", "install", "browser-control"] {
1853 assert!(
1854 completion.contains(expected),
1855 "zsh completion missing '{}'",
1856 expected
1857 );
1858 }
1859 }
1860
1861 #[cfg(feature = "agent")]
1862 #[test]
1863 fn test_config_print_includes_agent_fields() {
1864 let mut default_args = Args::parse_from(["passless"]);
1865 let config = AppConfig::from(&mut default_args.config);
1866 let toml_output = config.to_toml_with_comments();
1867
1868 assert!(
1869 toml_output.contains("backend_type"),
1870 "config print missing backend_type"
1871 );
1872 assert!(
1873 toml_output.contains("[security]"),
1874 "config print missing [security] section"
1875 );
1876 assert!(
1877 toml_output.contains("[pin]"),
1878 "config print missing [pin] section"
1879 );
1880 assert!(
1881 toml_output.contains("always_uv"),
1882 "config print missing always_uv"
1883 );
1884 assert!(
1885 toml_output.contains("notification_timeout"),
1886 "config print missing notification_timeout"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_config_print_contains_passless_header() {
1892 let mut default_args = Args::parse_from(["passless"]);
1893 let config = AppConfig::from(&mut default_args.config);
1894 let toml_output = config.to_toml_with_comments();
1895
1896 assert!(toml_output.contains("Passless Configuration File"));
1897 assert!(toml_output.contains("~/.config/passless/config.toml"));
1898 }
1899
1900 #[test]
1901 fn test_config_print_contains_local_backend_section() {
1902 let mut default_args = Args::parse_from(["passless"]);
1903 let config = AppConfig::from(&mut default_args.config);
1904 let toml_output = config.to_toml_with_comments();
1905
1906 assert!(toml_output.contains("[local]"));
1907 assert!(toml_output.contains("path"));
1908 }
1909
1910 #[test]
1911 fn test_config_print_contains_pass_backend_section() {
1912 let mut default_args = Args::parse_from(["passless"]);
1913 let config = AppConfig::from(&mut default_args.config);
1914 let toml_output = config.to_toml_with_comments();
1915
1916 assert!(toml_output.contains("[pass]"));
1917 assert!(toml_output.contains("store_path"));
1918 assert!(toml_output.contains("gpg_backend"));
1919 }
1920}