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(
167 long = "enable-credential-backup",
168 env = "PASSLESS_ENABLE_CREDENTIAL_BACKUP",
169 action = ArgAction::Set,
170 require_equals = true,
171 num_args = 0..=1,
172 default_missing_value = "true"
173 )]
174 #[serde(default)]
175 pub enable_credential_backup: bool,
176
177 #[arg(
183 long = "always-uv",
184 env = "PASSLESS_ALWAYS_UV",
185 action = ArgAction::Set,
186 require_equals = true,
187 num_args = 0..=1,
188 default_value = "true",
189 default_missing_value = "true"
190 )]
191 #[serde(default)]
192 #[default(true)]
193 pub always_uv: bool,
194
195 #[arg(
197 long = "user-verification-registration",
198 env = "PASSLESS_USER_VERIFICATION_REGISTRATION"
199 )]
200 #[serde(default)]
201 #[default(true)]
202 pub user_verification_registration: bool,
203
204 #[arg(
206 long = "user-verification-authentication",
207 env = "PASSLESS_USER_VERIFICATION_AUTHENTICATION"
208 )]
209 #[serde(default)]
210 #[default(true)]
211 pub user_verification_authentication: bool,
212
213 #[arg(
215 long = "notification-timeout",
216 env = "PASSLESS_NOTIFICATION_TIMEOUT",
217 value_name = "SECONDS"
218 )]
219 #[serde(default)]
220 #[default(30)]
221 pub notification_timeout: u32,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "lowercase")]
227pub enum PinEnforcement {
228 Never,
230 #[default]
232 Optional,
233 Required,
235}
236
237impl std::str::FromStr for PinEnforcement {
238 type Err = String;
239
240 fn from_str(s: &str) -> Result<Self, Self::Err> {
241 match s.to_lowercase().as_str() {
242 "never" => Ok(PinEnforcement::Never),
243 "optional" => Ok(PinEnforcement::Optional),
244 "required" => Ok(PinEnforcement::Required),
245 _ => Err(format!(
246 "Invalid PIN enforcement '{}'. Must be: never, optional, or required",
247 s
248 )),
249 }
250 }
251}
252
253impl std::fmt::Display for PinEnforcement {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 match self {
256 PinEnforcement::Never => write!(f, "never"),
257 PinEnforcement::Optional => write!(f, "optional"),
258 PinEnforcement::Required => write!(f, "required"),
259 }
260 }
261}
262
263#[derive(ClapSerde, Debug, Clone, Serialize, Deserialize, ConfigDoc)]
265#[group(id = "pin")]
266pub struct PinConfig {
267 #[arg(
272 long = "pin-enforcement",
273 env = "PASSLESS_PIN_ENFORCEMENT",
274 value_name = "POLICY"
275 )]
276 #[serde(default)]
277 #[default(PinEnforcement::Optional)]
278 pub enforcement: PinEnforcement,
279
280 #[arg(
282 long = "pin-min-length",
283 env = "PASSLESS_PIN_MIN_LENGTH",
284 value_name = "LENGTH"
285 )]
286 #[serde(default)]
287 #[default(4)]
288 pub min_length: u8,
289
290 #[arg(
292 long = "pin-max-retries",
293 env = "PASSLESS_PIN_MAX_RETRIES",
294 value_name = "RETRIES"
295 )]
296 #[serde(default)]
297 #[default(8)]
298 pub max_retries: u8,
299
300 #[arg(
306 long = "pin-max-uv-retries",
307 env = "PASSLESS_PIN_MAX_UV_RETRIES",
308 value_name = "RETRIES"
309 )]
310 #[serde(default)]
311 #[default(8)]
312 pub max_uv_retries: u8,
313
314 #[arg(
317 long = "pin-auto-lock-timeout",
318 env = "PASSLESS_PIN_AUTO_LOCK_TIMEOUT",
319 value_name = "SECONDS"
320 )]
321 #[serde(default)]
322 #[default(0)]
323 pub auto_lock_timeout: u32,
324}
325
326impl PinConfig {
327 pub fn validate(&self) -> crate::error::Result<()> {
329 if self.min_length < 4 || self.min_length > 63 {
330 return Err(crate::error::Error::Config(format!(
331 "pin.min_length must be between 4 and 63, got {}",
332 self.min_length
333 )));
334 }
335 if self.max_retries == 0 {
336 return Err(crate::error::Error::Config(
337 "pin.max_retries must be greater than 0".to_string(),
338 ));
339 }
340 if self.max_uv_retries == 0 {
341 return Err(crate::error::Error::Config(
342 "pin.max_uv_retries must be greater than 0".to_string(),
343 ));
344 }
345 Ok(())
346 }
347}
348
349impl SecurityConfig {
350 pub fn apply_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
352 if self.disable_core_dumps {
353 self.disable_core_dumps_impl()?;
354 }
355 if self.check_mlock {
356 self.probe_mlock_capability()?;
357 }
358 Ok(())
359 }
360
361 fn disable_core_dumps_impl(&self) -> Result<(), Box<dyn std::error::Error>> {
363 debug!("Disabling core dumps to prevent credential leakage");
364 setrlimit(Resource::RLIMIT_CORE, 0, 0)?;
365 let r = unsafe { prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) };
366 if r != 0 {
367 log::warn!("prctl(PR_SET_DUMPABLE) failed: {}", r);
368 }
369 Ok(())
370 }
371
372 fn probe_mlock_capability(&self) -> Result<(), Box<dyn std::error::Error>> {
374 debug!("Check mlock capability");
375
376 let test_size = 4096;
377 let test_buffer = vec![0u8; test_size];
378 let ptr = test_buffer.as_ptr() as *const libc::c_void;
379
380 let lock_result = unsafe { mlock(ptr, test_size) };
381
382 if lock_result == 0 {
383 unsafe { munlock(ptr, test_size) };
384 log::debug!("MLOCK is enabled - sensitive data will not be swapped to disk");
385 } else {
386 log::warn!(
387 "mlock capability probe failed - memory locking may not be available.\n\
388 Hint: grant CAP_IPC_LOCK to the binary with: 'sudo setcap cap_ipc_lock=+ep $(which passless)'"
389 );
390 }
391 Ok(())
392 }
393}
394
395#[derive(ClapSerde, Serialize, Deserialize, Debug, ConfigDoc)]
398pub struct AppConfig {
399 #[arg(short = 't', long = "backend-type", env = "PASSLESS_BACKEND_TYPE")]
401 #[serde(default)]
402 #[default("pass".to_string())]
403 pub backend_type: String,
404
405 #[arg(
408 short,
409 long,
410 env = "PASSLESS_VERBOSE",
411 action = ArgAction::Set,
412 require_equals = true,
413 num_args = 0..=1,
414 default_missing_value = "true"
415 )]
416 #[default(true)]
417 #[serde(default)]
418 pub verbose: bool,
419
420 #[clap_serde]
422 #[serde(default)]
423 #[command(flatten)]
424 pub pass: PassBackendConfig,
425
426 #[cfg(feature = "tpm")]
428 #[clap_serde]
429 #[serde(default)]
430 #[command(flatten)]
431 pub tpm: TpmBackendConfig,
432
433 #[clap_serde]
435 #[serde(default)]
436 #[command(flatten)]
437 pub local: LocalBackendConfig,
438
439 #[clap_serde]
441 #[serde(default)]
442 #[command(flatten)]
443 pub security: SecurityConfig,
444
445 #[clap_serde]
447 #[serde(default)]
448 #[command(flatten)]
449 pub pin: PinConfig,
450
451 #[cfg(feature = "agent")]
453 #[arg(skip)]
454 pub agents: AgentConfig,
455}
456
457#[derive(Debug, Clone)]
459pub enum BackendConfig {
460 Local {
461 path: String,
462 },
463 Pass {
464 store_path: String,
465 path: String,
466 gpg_backend: String,
467 },
468 #[cfg(feature = "tpm")]
469 Tpm {
470 path: String,
471 tcti: String,
472 portable: bool,
473 },
474}
475
476impl BackendConfig {
477 pub fn canonicalize_path(path: &Path) -> PathBuf {
482 match fs::canonicalize(path) {
483 Ok(p) => p,
484 Err(_) => {
485 let mut current = path.to_path_buf();
486 let mut suffix = Vec::new();
487 loop {
488 match fs::canonicalize(¤t) {
489 Ok(base) => {
490 let mut result = base;
491 for component in suffix.iter().rev() {
492 result.push(component);
493 }
494 return result;
495 }
496 Err(_) => {
497 if let Some(file_name) = current.file_name() {
498 suffix.push(file_name.to_os_string());
499 current = current
500 .parent()
501 .map(|p| p.to_path_buf())
502 .unwrap_or_default();
503 } else {
504 return path.to_path_buf();
505 }
506 }
507 }
508 }
509 }
510 }
511 }
512
513 pub fn state_path(&self) -> PathBuf {
518 match self {
519 BackendConfig::Local { path } => Self::canonicalize_path(Path::new(path)),
520 BackendConfig::Pass {
521 store_path, path, ..
522 } => Self::canonicalize_path(&Path::new(store_path).join(path)),
523 #[cfg(feature = "tpm")]
524 BackendConfig::Tpm { path, .. } => Self::canonicalize_path(Path::new(path)),
525 }
526 }
527
528 pub fn state_display(&self) -> String {
530 match self {
531 BackendConfig::Local { path } => path.clone(),
532 BackendConfig::Pass {
533 store_path, path, ..
534 } => {
535 format!("{}/{}", store_path, path)
536 }
537 #[cfg(feature = "tpm")]
538 BackendConfig::Tpm { path, .. } => path.clone(),
539 }
540 }
541
542 pub fn validate(&self) -> crate::error::Result<()> {
546 match self {
547 BackendConfig::Local { path } => {
548 let p = Path::new(path);
549 if !p.is_absolute() && !p.starts_with("~") {
550 debug!("Local backend path is relative: {}, canonicalizing", path);
551 }
552 Ok(())
553 }
554 BackendConfig::Pass {
555 store_path, path, ..
556 } => {
557 let p = Path::new(path);
558 if p.is_absolute() {
559 return Err(Error::Config(format!(
560 "Pass backend 'path' must be relative, got absolute path: {}",
561 path
562 )));
563 }
564 if path.contains("..") {
565 return Err(Error::Config(format!(
566 "Pass backend 'path' must not contain '..': {}",
567 path
568 )));
569 }
570 let combined = Path::new(store_path).join(path);
572 let canonical_store = Self::canonicalize_path(Path::new(store_path));
573 let canonical_combined = Self::canonicalize_path(&combined);
574 if !canonical_combined.starts_with(&canonical_store) {
575 return Err(Error::Config(format!(
576 "Pass backend 'path' escapes store_path: {} not beneath {}",
577 canonical_combined.display(),
578 canonical_store.display()
579 )));
580 }
581 Ok(())
582 }
583 #[cfg(feature = "tpm")]
584 BackendConfig::Tpm { path, .. } => {
585 let p = Path::new(path);
586 if !p.is_absolute() && !p.starts_with("~") {
587 debug!("TPM backend path is relative: {}, canonicalizing", path);
588 }
589 Ok(())
590 }
591 }
592 }
593}
594
595impl AppConfig {
596 pub fn load(args: &mut Args) -> crate::error::Result<Self> {
598 let default_config_path = dirs::config_dir().map(|p| p.join("passless/config.toml"));
599
600 let config_file_path = args
601 .config_path
602 .as_ref()
603 .or(default_config_path.as_ref())
604 .filter(|p| p.exists());
605
606 if let Some(path) = config_file_path
607 && let Ok(f) = File::open(path)
608 {
609 log::info!("Loading configuration from: {}", path.display());
610 let content = std::io::read_to_string(BufReader::new(f)).unwrap_or_default();
611
612 #[cfg(feature = "agent")]
613 let agent_config = {
614 match toml::from_str::<toml::Table>(&content) {
615 Ok(table) => match table.get("agents") {
616 Some(agents_value) => serde::Deserialize::deserialize(agents_value.clone())
617 .map_err(|e| {
618 Error::Config(format!(
619 "failed to parse [agents] section in {}: {}",
620 path.display(),
621 e
622 ))
623 })?,
624 None => AgentConfig::default(),
625 },
626 Err(e) => {
627 return Err(Error::Config(format!(
628 "failed to parse config file {} as TOML: {}",
629 path.display(),
630 e
631 )));
632 }
633 }
634 };
635
636 match toml::from_str::<<AppConfig as ClapSerde>::Opt>(&content) {
637 Ok(file_config) => {
638 #[allow(unused_mut)]
639 let mut config = AppConfig::from(file_config).merge(&mut args.config);
640 #[cfg(feature = "agent")]
641 {
642 config.agents = agent_config;
643 }
644 return Ok(config);
645 }
646 Err(e) => {
647 return Err(Error::Config(format!(
648 "failed to parse config file {}: {}",
649 path.display(),
650 e
651 )));
652 }
653 }
654 }
655
656 #[allow(unused_mut)]
657 let mut config = AppConfig::from(&mut args.config);
658 #[cfg(feature = "agent")]
659 {
660 config.agents = AgentConfig::default();
661 }
662 Ok(config)
663 }
664
665 pub fn backend(&self) -> crate::error::Result<BackendConfig> {
667 match self.backend_type.as_str() {
668 "local" => Ok(BackendConfig::Local {
669 path: self.local.path.clone(),
670 }),
671 "pass" => Ok(BackendConfig::Pass {
672 store_path: self.pass.store_path.clone(),
673 path: self.pass.path.clone(),
674 gpg_backend: self.pass.gpg_backend.clone(),
675 }),
676 #[cfg(feature = "tpm")]
677 "tpm" => Ok(BackendConfig::Tpm {
678 path: self.tpm.path.clone(),
679 tcti: self.tpm.tcti.clone(),
680 portable: self.tpm.portable,
681 }),
682 _ => Err(crate::error::Error::Config(format!(
683 "Invalid backend_type '{}'. Must be one of: local, pass, tpm",
684 self.backend_type
685 ))),
686 }
687 }
688
689 pub fn apply_security_hardening(&self) -> Result<(), Box<dyn std::error::Error>> {
691 self.security.apply_hardening()
692 }
693
694 pub fn security_config(&self) -> SecurityConfig {
696 self.security.clone()
697 }
698
699 pub fn pin_config(&self) -> PinConfig {
701 self.pin.clone()
702 }
703
704 pub fn validate(&self) -> crate::error::Result<()> {
706 self.pin.validate()?;
707 #[cfg(feature = "agent")]
708 {
709 let human_path = self.backend().ok().map(|b| b.state_path());
710 self.agents.validate(human_path.as_deref())?;
711 }
712 Ok(())
713 }
714}
715
716#[derive(Parser)]
718#[command(author, version, about)]
719pub struct Args {
720 #[arg(short, long, env = "PASSLESS_CONFIG")]
722 pub config_path: Option<PathBuf>,
723
724 #[command(flatten)]
726 pub config: <AppConfig as ClapSerde>::Opt,
727
728 #[command(subcommand)]
730 pub command: Option<Commands>,
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub enum OutputFormat {
736 Plain,
738 Json,
740}
741
742impl std::str::FromStr for OutputFormat {
743 type Err = String;
744
745 fn from_str(s: &str) -> Result<Self, Self::Err> {
746 match s.to_lowercase().as_str() {
747 "plain" => Ok(OutputFormat::Plain),
748 "json" => Ok(OutputFormat::Json),
749 _ => Err(format!(
750 "Invalid output format '{}'. Must be 'plain' or 'json'",
751 s
752 )),
753 }
754 }
755}
756
757impl std::fmt::Display for OutputFormat {
758 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759 match self {
760 OutputFormat::Plain => write!(f, "plain"),
761 OutputFormat::Json => write!(f, "json"),
762 }
763 }
764}
765
766#[derive(Subcommand, Debug, Clone)]
768pub enum Commands {
769 Config {
771 #[command(subcommand)]
772 action: ConfigAction,
773 },
774 Client {
780 #[arg(short = 'D', long = "device", value_name = "INDEX|NAME", global = true)]
782 device: Option<String>,
783
784 #[arg(
786 short = 'o',
787 long = "output",
788 value_name = "FORMAT",
789 default_value = "plain",
790 global = true
791 )]
792 output: OutputFormat,
793
794 #[command(subcommand)]
795 action: ClientAction,
796 },
797 #[cfg(feature = "agent")]
799 AgentAdmin {
800 #[arg(
802 short = 'o',
803 long = "output",
804 value_name = "FORMAT",
805 default_value = "json",
806 global = true
807 )]
808 output: OutputFormat,
809
810 #[command(subcommand)]
811 action: AgentAdminAction,
812 },
813 #[cfg(feature = "agent")]
815 Agent {
816 #[arg(long, value_name = "PROFILE", global = true)]
818 profile: Option<String>,
819
820 #[arg(
822 short = 'o',
823 long = "output",
824 value_name = "FORMAT",
825 default_value = "json",
826 global = true
827 )]
828 output: OutputFormat,
829
830 #[command(subcommand)]
831 action: crate::AgentCommand,
832 },
833 #[cfg(feature = "tpm")]
835 Tpm {
836 #[command(subcommand)]
837 action: TpmAction,
838 },
839}
840
841#[cfg(feature = "tpm")]
843#[derive(Subcommand, Debug, Clone)]
844pub enum TpmAction {
845 #[command(group(clap::ArgGroup::new("seed-source").args(["generate", "seed_file", "seed_stdin"])))]
847 Provision {
848 #[arg(long)]
850 generate: bool,
851 #[arg(long = "seed-file", value_name = "PATH")]
853 seed_file: Option<PathBuf>,
854 #[arg(long = "seed-stdin")]
856 seed_stdin: bool,
857 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
859 path: Option<String>,
860 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
862 tcti: Option<String>,
863 },
864 Status {
866 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
868 path: Option<String>,
869 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
871 tcti: Option<String>,
872 },
873 Remove {
875 #[arg(long)]
877 confirm: bool,
878 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
880 path: Option<String>,
881 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
883 tcti: Option<String>,
884 },
885 #[command(group(clap::ArgGroup::new("selection").args(["credential_id", "all"]).required(true)))]
887 Migrate {
888 #[arg(long = "credential-id", value_name = "ID")]
890 credential_id: Option<String>,
891 #[arg(long)]
893 all: bool,
894 #[arg(long)]
896 dry_run: bool,
897 #[arg(long = "backup-dir", value_name = "PATH")]
899 backup_dir: Option<String>,
900 #[arg(long = "tpm-path", env = "PASSLESS_TPM_PATH")]
902 path: Option<String>,
903 #[arg(long = "tpm-tcti", env = "PASSLESS_TPM_TCTI")]
905 tcti: Option<String>,
906 },
907}
908
909#[derive(Subcommand, Debug, Clone)]
911pub enum ConfigAction {
912 Print,
914}
915
916#[cfg(feature = "agent")]
918#[derive(Subcommand, Debug, Clone)]
919pub enum AgentAdminAction {
920 Install {
922 #[arg(value_enum, default_value_t = AgentSkillTarget::Auto)]
924 target: AgentSkillTarget,
925
926 #[arg(long, value_enum, default_value_t = AgentSkillScope::User)]
928 scope: AgentSkillScope,
929
930 #[arg(long)]
932 force: bool,
933 },
934 Profile {
936 #[command(subcommand)]
937 action: AdminProfileAction,
938 },
939 Policy {
941 #[command(subcommand)]
942 action: AdminPolicyAction,
943 },
944 Credential {
946 #[command(subcommand)]
947 action: AdminCredentialAction,
948 },
949 Delegation {
951 #[command(subcommand)]
952 action: AdminDelegationAction,
953 },
954 Session {
956 #[command(subcommand)]
957 action: AdminSessionAction,
958 },
959 Audit {
961 #[command(subcommand)]
962 action: AdminAuditAction,
963 },
964 #[command(hide = true)]
966 Shutdown {
967 #[arg(long)]
969 confirm: bool,
970 },
971}
972
973#[cfg(feature = "agent")]
975#[derive(Subcommand, Debug, Clone)]
976pub enum AdminProfileAction {
977 Check {
979 #[arg(value_name = "PROFILE")]
981 profile: String,
982 },
983 Show {
985 #[arg(value_name = "PROFILE")]
987 profile: String,
988 },
989 List,
991 Enable {
993 #[arg(value_name = "PROFILE")]
995 profile: String,
996 },
997 Disable {
999 #[arg(value_name = "PROFILE")]
1001 profile: String,
1002 },
1003}
1004
1005#[cfg(feature = "agent")]
1007#[derive(Subcommand, Debug, Clone)]
1008pub enum AdminPolicyAction {
1009 Check {
1011 #[arg(value_name = "PROFILE")]
1013 profile: String,
1014 },
1015 Reload {
1017 #[arg(value_name = "PROFILE")]
1019 profile: String,
1020 },
1021 Show {
1023 #[arg(value_name = "PROFILE")]
1025 profile: String,
1026 },
1027}
1028
1029#[cfg(feature = "agent")]
1031#[derive(Subcommand, Debug, Clone)]
1032pub enum AdminCredentialAction {
1033 List {
1035 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1037 rp_id: Option<String>,
1038 },
1039 Show {
1041 #[arg(value_name = "CREDENTIAL_REF")]
1043 credential_ref: String,
1044 },
1045 Revoke {
1047 #[arg(value_name = "CREDENTIAL_REF")]
1049 credential_ref: String,
1050 #[arg(long)]
1052 confirm: bool,
1053 },
1054 Delete {
1056 #[arg(value_name = "CREDENTIAL_REF")]
1058 credential_ref: String,
1059 #[arg(long)]
1061 confirm: bool,
1062 },
1063}
1064
1065#[cfg(feature = "agent")]
1067#[derive(Subcommand, Debug, Clone)]
1068pub enum AdminDelegationAction {
1069 Show {
1071 #[arg(value_name = "GRANT_ID")]
1073 grant_id: String,
1074 },
1075 List {
1077 #[arg(long, value_name = "PROFILE")]
1079 profile: Option<String>,
1080 },
1081 Revoke {
1083 #[arg(value_name = "GRANT_ID")]
1085 grant_id: String,
1086 #[arg(long)]
1088 confirm: bool,
1089 },
1090}
1091
1092#[cfg(feature = "agent")]
1094#[derive(Subcommand, Debug, Clone)]
1095pub enum AdminSessionAction {
1096 Show {
1098 #[arg(value_name = "SESSION_ID")]
1100 session_id: String,
1101 },
1102 List {
1104 #[arg(long, value_name = "PROFILE")]
1106 profile: Option<String>,
1107 },
1108 Revoke {
1110 #[arg(value_name = "SESSION_ID")]
1112 session_id: String,
1113 #[arg(long)]
1115 confirm: bool,
1116 },
1117}
1118
1119#[cfg(feature = "agent")]
1121#[derive(Subcommand, Debug, Clone)]
1122pub enum AdminAuditAction {
1123 Status,
1125 Verify,
1127 Export {
1129 #[arg(long, value_enum, default_value_t = AdminAuditExportFormat::Json)]
1131 format: AdminAuditExportFormat,
1132 },
1133}
1134
1135#[cfg(feature = "agent")]
1137#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1138pub enum AdminAuditExportFormat {
1139 Json,
1140 Csv,
1141}
1142
1143#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1145pub enum AgentSkillTarget {
1146 Auto,
1147 Opencode,
1148 Claude,
1149 Pi,
1150}
1151
1152#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1154pub enum AgentSkillScope {
1155 User,
1156 Project,
1157}
1158
1159#[derive(Subcommand, Debug, Clone)]
1161pub enum ClientAction {
1162 Devices,
1164 Info,
1166 Reset {
1168 #[arg(long = "yes-i-really-want-to-reset-my-device", action = ArgAction::Count)]
1170 confirm: u8,
1171 },
1172 List {
1174 #[arg(short = 'd', long = "domain", value_name = "DOMAIN")]
1176 rp_id: Option<String>,
1177 },
1178 Show {
1180 #[arg(value_name = "CREDENTIAL_ID")]
1182 credential_id: String,
1183 },
1184 Delete {
1186 #[arg(value_name = "CREDENTIAL_ID")]
1188 credential_id: String,
1189 },
1190 Rename {
1192 #[arg(value_name = "CREDENTIAL_ID")]
1194 credential_id: String,
1195 #[arg(short = 'u', long = "user-name", value_name = "NAME")]
1197 user_name: Option<String>,
1198 #[arg(short = 'n', long = "display-name", value_name = "NAME")]
1200 display_name: Option<String>,
1201 },
1202 Backup {
1204 #[arg(value_name = "CREDENTIAL_ID")]
1206 credential_id: String,
1207 #[arg(long, value_name = "RECIPIENT")]
1209 recipient: String,
1210 #[arg(long, value_name = "PATH")]
1212 output_file: PathBuf,
1213 #[arg(long = "yes-i-understand-this-exports-a-passkey")]
1215 confirm: bool,
1216 },
1217 Restore {
1219 #[arg(value_name = "PATH")]
1221 input_file: PathBuf,
1222 #[arg(long)]
1224 replace: bool,
1225 #[arg(long = "yes-i-understand-this-restores-a-passkey")]
1227 confirm: bool,
1228 },
1229 Pin {
1231 #[command(subcommand)]
1232 action: PinAction,
1233 },
1234}
1235
1236#[derive(Subcommand, Debug, Clone)]
1238pub enum PinAction {
1239 Set {
1241 #[arg(value_name = "PIN")]
1243 pin: String,
1244 },
1245 Change {
1247 #[arg(value_name = "OLD_PIN")]
1249 old_pin: String,
1250 #[arg(value_name = "NEW_PIN")]
1252 new_pin: String,
1253 },
1254 UvReset,
1256}
1257
1258#[cfg(feature = "agent")]
1260#[derive(Subcommand, Debug, Clone)]
1261pub enum AgentCommand {
1262 Doctor,
1264 Capabilities,
1266 Instructions,
1268 Intent {
1270 #[command(subcommand)]
1271 action: AgentIntentAction,
1272 },
1273 Delegation {
1275 #[command(subcommand)]
1276 action: AgentDelegationAction,
1277 },
1278 Credential {
1280 #[command(subcommand)]
1281 action: AgentCredentialAction,
1282 },
1283 BrowserStatus,
1285 EndpointStatus,
1287 BrowserControl {
1293 #[arg(long, value_name = "JSON", conflicts_with = "request_file")]
1295 request: Option<String>,
1296 #[arg(long, value_name = "PATH", conflicts_with = "request")]
1298 request_file: Option<std::path::PathBuf>,
1299 #[arg(long, value_name = "MS", default_value = "5000")]
1301 timeout_ms: u32,
1302 },
1303 Run {
1305 #[arg(long, value_name = "PROFILE")]
1307 profile: String,
1308 #[arg(last = true, required = true)]
1310 command: Vec<std::path::PathBuf>,
1311 },
1312}
1313
1314#[cfg(feature = "agent")]
1316#[derive(Subcommand, Debug, Clone)]
1317pub enum AgentIntentAction {
1318 Create {
1320 #[arg(value_enum)]
1322 action: AgentIntentActionType,
1323 #[arg(long, value_name = "RP_ID")]
1325 rp: String,
1326 #[arg(long, value_name = "CREDENTIAL_REF")]
1328 credential: Option<String>,
1329 #[arg(long, value_name = "REASON")]
1331 reason: Option<String>,
1332 },
1333 Show {
1335 #[arg(value_name = "REQUEST_ID")]
1337 request_id: String,
1338 },
1339 Wait {
1341 #[arg(value_name = "REQUEST_ID")]
1343 request_id: String,
1344 #[arg(long, value_name = "SECONDS")]
1346 timeout: Option<u64>,
1347 #[arg(long, value_name = "MS")]
1349 poll_interval: Option<u64>,
1350 },
1351 Cancel {
1353 #[arg(value_name = "REQUEST_ID")]
1355 request_id: String,
1356 },
1357}
1358
1359#[cfg(feature = "agent")]
1361#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1362pub enum AgentIntentActionType {
1363 Register,
1364 Authenticate,
1365}
1366
1367#[cfg(feature = "agent")]
1369#[derive(Subcommand, Debug, Clone)]
1370pub enum AgentDelegationAction {
1371 Request {
1373 #[arg(long, value_name = "RP_ID")]
1375 rp: String,
1376 #[arg(long, value_name = "CREDENTIAL_REF")]
1378 credential: String,
1379 #[arg(long, value_name = "SECONDS")]
1381 session_ttl: u64,
1382 #[arg(long, value_name = "REASON")]
1384 reason: Option<String>,
1385 },
1386 Show {
1388 #[arg(value_name = "REQUEST_ID")]
1390 request_id: String,
1391 },
1392 Wait {
1394 #[arg(value_name = "REQUEST_ID")]
1396 request_id: String,
1397 #[arg(long, value_name = "SECONDS")]
1399 timeout: Option<u64>,
1400 #[arg(long, value_name = "MS")]
1402 poll_interval: Option<u64>,
1403 },
1404 Cancel {
1406 #[arg(value_name = "REQUEST_ID")]
1408 request_id: String,
1409 },
1410}
1411
1412#[cfg(feature = "agent")]
1414#[derive(Subcommand, Debug, Clone)]
1415pub enum AgentCredentialAction {
1416 List,
1418 Show {
1420 #[arg(value_name = "CREDENTIAL_REF")]
1422 credential_ref: String,
1423 },
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::*;
1429
1430 #[cfg(feature = "agent")]
1431 #[test]
1432 fn test_agent_admin_install_defaults() {
1433 let args = Args::try_parse_from(["passless", "agent-admin", "install"]).unwrap();
1434 assert!(matches!(
1435 args.command,
1436 Some(Commands::AgentAdmin {
1437 action: AgentAdminAction::Install {
1438 target: AgentSkillTarget::Auto,
1439 scope: AgentSkillScope::User,
1440 force: false,
1441 },
1442 ..
1443 })
1444 ));
1445 }
1446
1447 #[cfg(feature = "agent")]
1448 #[test]
1449 fn test_agent_admin_install_explicit_options() {
1450 let args = Args::try_parse_from([
1451 "passless",
1452 "agent-admin",
1453 "install",
1454 "claude",
1455 "--scope",
1456 "project",
1457 "--force",
1458 ])
1459 .unwrap();
1460 assert!(matches!(
1461 args.command,
1462 Some(Commands::AgentAdmin {
1463 action: AgentAdminAction::Install {
1464 target: AgentSkillTarget::Claude,
1465 scope: AgentSkillScope::Project,
1466 force: true,
1467 },
1468 ..
1469 })
1470 ));
1471 }
1472
1473 #[test]
1474 fn test_pin_config_default_max_uv_retries() {
1475 let config = PinConfig {
1476 enforcement: PinEnforcement::Optional,
1477 min_length: 4,
1478 max_retries: 8,
1479 max_uv_retries: 8,
1480 auto_lock_timeout: 0,
1481 };
1482 assert_eq!(config.max_uv_retries, 8);
1483 }
1484
1485 #[test]
1486 fn test_pin_config_validate_success() {
1487 let config = PinConfig {
1488 enforcement: PinEnforcement::Optional,
1489 min_length: 4,
1490 max_retries: 8,
1491 max_uv_retries: 8,
1492 auto_lock_timeout: 0,
1493 };
1494 assert!(config.validate().is_ok());
1495 }
1496
1497 #[test]
1498 fn test_pin_config_validate_zero_max_uv_retries() {
1499 let config = PinConfig {
1500 enforcement: PinEnforcement::Optional,
1501 min_length: 4,
1502 max_retries: 8,
1503 max_uv_retries: 0,
1504 auto_lock_timeout: 0,
1505 };
1506 let result = config.validate();
1507 assert!(result.is_err());
1508 assert!(result.unwrap_err().to_string().contains("max_uv_retries"));
1509 }
1510
1511 #[test]
1512 fn test_pin_config_validate_zero_max_retries() {
1513 let config = PinConfig {
1514 enforcement: PinEnforcement::Optional,
1515 min_length: 4,
1516 max_retries: 0,
1517 max_uv_retries: 8,
1518 auto_lock_timeout: 0,
1519 };
1520 let result = config.validate();
1521 assert!(result.is_err());
1522 assert!(result.unwrap_err().to_string().contains("max_retries"));
1523 }
1524
1525 #[test]
1526 fn test_pin_config_validate_invalid_min_length() {
1527 let config = PinConfig {
1528 enforcement: PinEnforcement::Optional,
1529 min_length: 3,
1530 max_retries: 8,
1531 max_uv_retries: 8,
1532 auto_lock_timeout: 0,
1533 };
1534 let result = config.validate();
1535 assert!(result.is_err());
1536 assert!(result.unwrap_err().to_string().contains("min_length"));
1537 }
1538
1539 #[test]
1540 fn test_canonicalize_path_existing() {
1541 let dir = std::env::temp_dir();
1542 let canonical = BackendConfig::canonicalize_path(&dir);
1543 assert!(canonical.is_absolute());
1544 assert!(canonical.exists());
1545 }
1546
1547 #[test]
1548 fn test_canonicalize_path_nonexistent() {
1549 let base = std::env::temp_dir();
1550 let nonexistent = base.join("passless_test_nonexistent_dir_12345/sub");
1551 let canonical = BackendConfig::canonicalize_path(&nonexistent);
1552 assert!(canonical.is_absolute());
1553 assert!(canonical.starts_with(BackendConfig::canonicalize_path(&base)));
1554 }
1555
1556 #[test]
1557 fn test_canonicalize_path_symlink() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let real = dir.path().join("real");
1560 std::fs::create_dir(&real).unwrap();
1561 let link = dir.path().join("link");
1562 std::os::unix::fs::symlink(&real, &link).unwrap();
1563
1564 let canonical_real = BackendConfig::canonicalize_path(&real);
1565 let canonical_link = BackendConfig::canonicalize_path(&link);
1566 assert_eq!(canonical_real, canonical_link);
1567 }
1568
1569 #[test]
1570 fn test_local_state_path_relative_and_absolute() {
1571 let dir = tempfile::tempdir_in(".").unwrap();
1572 let abs_path = std::fs::canonicalize(dir.path()).unwrap();
1573 let rel_path = dir.path().to_path_buf();
1574
1575 let backend_abs = BackendConfig::Local {
1576 path: abs_path.display().to_string(),
1577 };
1578 let backend_rel = BackendConfig::Local {
1579 path: rel_path.display().to_string(),
1580 };
1581 assert_eq!(backend_abs.state_path(), backend_rel.state_path());
1582 }
1583
1584 #[test]
1585 fn test_pass_state_path_different_subpaths() {
1586 let store = "/tmp/passless_test_store";
1587 let backend_a = BackendConfig::Pass {
1588 store_path: store.to_string(),
1589 path: "fido2".to_string(),
1590 gpg_backend: "gnupg-bin".to_string(),
1591 };
1592 let backend_b = BackendConfig::Pass {
1593 store_path: store.to_string(),
1594 path: "fido2-other".to_string(),
1595 gpg_backend: "gnupg-bin".to_string(),
1596 };
1597 assert_ne!(backend_a.state_path(), backend_b.state_path());
1598 }
1599
1600 #[test]
1601 fn test_different_local_paths_produce_different_identities() {
1602 let backend_a = BackendConfig::Local {
1603 path: "/tmp/passless_a".to_string(),
1604 };
1605 let backend_b = BackendConfig::Local {
1606 path: "/tmp/passless_b".to_string(),
1607 };
1608 assert_ne!(backend_a.state_path(), backend_b.state_path());
1609 }
1610
1611 #[cfg(feature = "agent")]
1612 #[test]
1613 fn test_agent_admin_profile_list() {
1614 let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1615 assert!(matches!(
1616 args.command,
1617 Some(Commands::AgentAdmin {
1618 action: AgentAdminAction::Profile {
1619 action: AdminProfileAction::List,
1620 },
1621 ..
1622 })
1623 ));
1624 }
1625
1626 #[cfg(feature = "agent")]
1627 #[test]
1628 fn test_agent_admin_credential_delete_without_confirm() {
1629 let args = Args::try_parse_from([
1630 "passless",
1631 "agent-admin",
1632 "credential",
1633 "delete",
1634 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1635 ])
1636 .unwrap();
1637 assert!(matches!(
1638 args.command,
1639 Some(Commands::AgentAdmin {
1640 action: AgentAdminAction::Credential {
1641 action: AdminCredentialAction::Delete { confirm: false, .. },
1642 },
1643 ..
1644 })
1645 ));
1646 }
1647
1648 #[cfg(feature = "agent")]
1649 #[test]
1650 fn test_agent_admin_credential_delete_with_confirm() {
1651 let args = Args::try_parse_from([
1652 "passless",
1653 "agent-admin",
1654 "credential",
1655 "delete",
1656 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1657 "--confirm",
1658 ])
1659 .unwrap();
1660 assert!(matches!(
1661 args.command,
1662 Some(Commands::AgentAdmin {
1663 action: AgentAdminAction::Credential {
1664 action: AdminCredentialAction::Delete { confirm: true, .. },
1665 },
1666 ..
1667 })
1668 ));
1669 }
1670
1671 #[cfg(feature = "agent")]
1672 #[test]
1673 fn test_agent_admin_shutdown_hidden() {
1674 let args =
1675 Args::try_parse_from(["passless", "agent-admin", "shutdown", "--confirm"]).unwrap();
1676 assert!(matches!(
1677 args.command,
1678 Some(Commands::AgentAdmin {
1679 action: AgentAdminAction::Shutdown { confirm: true },
1680 ..
1681 })
1682 ));
1683 }
1684
1685 #[cfg(feature = "agent")]
1686 #[test]
1687 fn test_agent_admin_output_default_json() {
1688 let args = Args::try_parse_from(["passless", "agent-admin", "profile", "list"]).unwrap();
1689 match args.command {
1690 Some(Commands::AgentAdmin { output, .. }) => {
1691 assert_eq!(output, OutputFormat::Json);
1692 }
1693 _ => panic!("expected AgentAdmin command"),
1694 }
1695 }
1696
1697 #[cfg(feature = "agent")]
1698 #[test]
1699 fn test_agent_admin_output_plain() {
1700 let args = Args::try_parse_from([
1701 "passless",
1702 "agent-admin",
1703 "--output",
1704 "plain",
1705 "profile",
1706 "list",
1707 ])
1708 .unwrap();
1709 match args.command {
1710 Some(Commands::AgentAdmin { output, .. }) => {
1711 assert_eq!(output, OutputFormat::Plain);
1712 }
1713 _ => panic!("expected AgentAdmin command"),
1714 }
1715 }
1716
1717 #[cfg(feature = "agent")]
1718 #[test]
1719 fn test_agent_doctor_parses() {
1720 let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1721 assert!(matches!(
1722 args.command,
1723 Some(Commands::Agent {
1724 action: AgentCommand::Doctor,
1725 ..
1726 })
1727 ));
1728 }
1729
1730 #[cfg(feature = "agent")]
1731 #[test]
1732 fn test_agent_run_with_command() {
1733 let args = Args::try_parse_from([
1734 "passless",
1735 "agent",
1736 "run",
1737 "--profile",
1738 "myprofile",
1739 "--",
1740 "/usr/bin/test",
1741 "arg1",
1742 ])
1743 .unwrap();
1744 match args.command {
1745 Some(Commands::Agent {
1746 action: AgentCommand::Run { profile, command },
1747 ..
1748 }) => {
1749 assert_eq!(profile, "myprofile");
1750 assert_eq!(command.len(), 2);
1751 }
1752 _ => panic!("expected Agent Run command"),
1753 }
1754 }
1755
1756 #[cfg(feature = "agent")]
1757 #[test]
1758 fn test_agent_intent_create_parses() {
1759 let args = Args::try_parse_from([
1760 "passless",
1761 "agent",
1762 "intent",
1763 "create",
1764 "register",
1765 "--rp",
1766 "example.com",
1767 ])
1768 .unwrap();
1769 assert!(matches!(
1770 args.command,
1771 Some(Commands::Agent {
1772 action: AgentCommand::Intent {
1773 action: AgentIntentAction::Create {
1774 action: AgentIntentActionType::Register,
1775 ..
1776 },
1777 },
1778 ..
1779 })
1780 ));
1781 }
1782
1783 #[cfg(feature = "agent")]
1784 #[test]
1785 fn test_agent_output_default_json() {
1786 let args = Args::try_parse_from(["passless", "agent", "doctor"]).unwrap();
1787 match args.command {
1788 Some(Commands::Agent { output, .. }) => {
1789 assert_eq!(output, OutputFormat::Json);
1790 }
1791 _ => panic!("expected Agent command"),
1792 }
1793 }
1794
1795 #[cfg(feature = "agent")]
1796 #[test]
1797 fn test_shell_completions_contain_agent_commands() {
1798 use clap::CommandFactory;
1799
1800 let cmd = Args::command();
1801 let mut buf = Vec::new();
1802 clap_complete::generate(
1803 clap_complete::Shell::Bash,
1804 &mut cmd.clone(),
1805 "passless",
1806 &mut buf,
1807 );
1808 let completion = String::from_utf8(buf).unwrap();
1809
1810 for expected in [
1811 "agent-admin",
1812 "agent",
1813 "install",
1814 "browser-control",
1815 "intent",
1816 "delegation",
1817 "doctor",
1818 "capabilities",
1819 "instructions",
1820 ] {
1821 assert!(
1822 completion.contains(expected),
1823 "bash completion missing '{}'",
1824 expected
1825 );
1826 }
1827 }
1828
1829 #[cfg(feature = "agent")]
1830 #[test]
1831 fn test_shell_completions_zsh_contain_agent_commands() {
1832 use clap::CommandFactory;
1833
1834 let cmd = Args::command();
1835 let mut buf = Vec::new();
1836 clap_complete::generate(
1837 clap_complete::Shell::Zsh,
1838 &mut cmd.clone(),
1839 "passless",
1840 &mut buf,
1841 );
1842 let completion = String::from_utf8(buf).unwrap();
1843
1844 for expected in ["agent-admin", "agent", "install", "browser-control"] {
1845 assert!(
1846 completion.contains(expected),
1847 "zsh completion missing '{}'",
1848 expected
1849 );
1850 }
1851 }
1852
1853 #[cfg(feature = "agent")]
1854 #[test]
1855 fn test_config_print_includes_agent_fields() {
1856 let mut default_args = Args::parse_from(["passless"]);
1857 let config = AppConfig::from(&mut default_args.config);
1858 let toml_output = config.to_toml_with_comments();
1859
1860 assert!(
1861 toml_output.contains("backend_type"),
1862 "config print missing backend_type"
1863 );
1864 assert!(
1865 toml_output.contains("[security]"),
1866 "config print missing [security] section"
1867 );
1868 assert!(
1869 toml_output.contains("[pin]"),
1870 "config print missing [pin] section"
1871 );
1872 assert!(
1873 toml_output.contains("always_uv"),
1874 "config print missing always_uv"
1875 );
1876 assert!(
1877 toml_output.contains("notification_timeout"),
1878 "config print missing notification_timeout"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_config_print_contains_passless_header() {
1884 let mut default_args = Args::parse_from(["passless"]);
1885 let config = AppConfig::from(&mut default_args.config);
1886 let toml_output = config.to_toml_with_comments();
1887
1888 assert!(toml_output.contains("Passless Configuration File"));
1889 assert!(toml_output.contains("~/.config/passless/config.toml"));
1890 }
1891
1892 #[test]
1893 fn test_config_print_contains_local_backend_section() {
1894 let mut default_args = Args::parse_from(["passless"]);
1895 let config = AppConfig::from(&mut default_args.config);
1896 let toml_output = config.to_toml_with_comments();
1897
1898 assert!(toml_output.contains("[local]"));
1899 assert!(toml_output.contains("path"));
1900 }
1901
1902 #[test]
1903 fn test_config_print_contains_pass_backend_section() {
1904 let mut default_args = Args::parse_from(["passless"]);
1905 let config = AppConfig::from(&mut default_args.config);
1906 let toml_output = config.to_toml_with_comments();
1907
1908 assert!(toml_output.contains("[pass]"));
1909 assert!(toml_output.contains("store_path"));
1910 assert!(toml_output.contains("gpg_backend"));
1911 }
1912}