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