1pub mod cli;
17pub mod explain;
18pub mod manpages;
19pub mod providers;
20#[cfg(feature = "cloud-pull-aws")]
21pub use providers::aws as tsafe_aws;
22#[cfg(feature = "cloud-pull-gcp")]
23pub use providers::gcp as tsafe_gcp;
24#[cfg(feature = "cloud-pull-vault")]
25pub use providers::hcp as tsafe_hcp;
26#[cfg(feature = "cloud-pull-keepass")]
27pub use providers::keepass as tsafe_keepass;
28#[cfg(feature = "cloud-pull-1password")]
29pub use providers::onepassword as tsafe_op;
30
31#[cfg(any(feature = "cloud-pull-1password", feature = "cloud-pull-vault", test))]
35pub use op_mapping::op_field_label_to_key;
36
37pub mod op_mapping {
40 pub fn op_field_label_to_key(label: &str) -> String {
50 label.replace([' ', '-'], "_").to_uppercase()
51 }
52}
53
54#[cfg(feature = "agent")]
59mod cmd_agent;
60mod cmd_alias;
61mod cmd_attest;
62mod cmd_audit_cmd;
63#[cfg(feature = "cloud-pull-aws")]
64mod cmd_aws_pull;
65#[cfg(feature = "cloud-pull-aws")]
66mod cmd_aws_push;
67#[cfg(feature = "biometric")]
68mod cmd_biometric;
69#[cfg(feature = "cloud-pull-bitwarden")]
70mod cmd_bitwarden_pull;
71#[cfg(feature = "nativehost")]
72mod cmd_browser_native_host;
73#[cfg(feature = "browser")]
74mod cmd_browser_profile;
75#[cfg(feature = "collab")]
76mod cmd_collab;
77mod cmd_config_cmd;
78#[cfg(feature = "git-helpers")]
79mod cmd_credential_helper;
80mod cmd_diff;
81mod cmd_doctor;
82mod cmd_exec;
83#[cfg(feature = "cloud-pull-gcp")]
84mod cmd_gcp_pull;
85#[cfg(feature = "cloud-pull-gcp")]
86mod cmd_gcp_push;
87mod cmd_gen;
88#[cfg(feature = "git-helpers")]
89mod cmd_git;
90mod cmd_import;
91#[cfg(feature = "cloud-pull-keepass")]
92mod cmd_keepass_pull;
93#[cfg(feature = "akv-pull")]
94mod cmd_kv_pull;
95#[cfg(feature = "akv-pull")]
96mod cmd_kv_push;
97#[cfg(feature = "mcp")]
98mod cmd_mcp;
99mod cmd_mobile;
100mod cmd_ns;
101#[cfg(feature = "plugins")]
102mod cmd_plugin;
103mod cmd_policy;
104mod cmd_profile_cmd;
105#[cfg(feature = "multi-pull")]
106mod cmd_pull;
107#[cfg(feature = "akv-pull")]
108mod cmd_push;
109mod cmd_rotate;
110#[cfg(feature = "ots-sharing")]
111mod cmd_share;
112mod cmd_snapshot_cmd;
113#[cfg(feature = "ssh")]
114mod cmd_ssh;
115#[cfg(feature = "cloud-pull-aws")]
116mod cmd_ssm_pull;
117#[cfg(feature = "cloud-pull-aws")]
118mod cmd_ssm_push;
119#[cfg(feature = "git-helpers")]
120mod cmd_sync;
121#[cfg(feature = "team-core")]
122mod cmd_team;
123mod cmd_template;
124mod cmd_tooling;
125mod cmd_totp;
126mod cmd_validate;
127mod cmd_vault;
128#[cfg(any(feature = "cloud-pull-vault", feature = "cloud-pull-1password"))]
129mod cmd_vault_pull;
130mod helpers;
131#[cfg(all(windows, feature = "biometric"))]
132mod windows_hello;
133
134#[cfg(feature = "agent")]
137use cmd_agent::cmd_agent;
138use cmd_alias::{cmd_alias, cmd_history, cmd_mv};
139use cmd_attest::cmd_attest;
140use cmd_audit_cmd::{cmd_audit, cmd_audit_verify};
141#[cfg(feature = "cloud-pull-aws")]
142use cmd_aws_pull::cmd_aws_pull;
143#[cfg(feature = "cloud-pull-aws")]
144use cmd_aws_push::cmd_aws_push;
145#[cfg(feature = "biometric")]
146use cmd_biometric::cmd_biometric;
147#[cfg(feature = "cloud-pull-bitwarden")]
148use cmd_bitwarden_pull::cmd_bitwarden_pull;
149#[cfg(feature = "nativehost")]
150use cmd_browser_native_host::cmd_browser_native_host;
151#[cfg(feature = "browser")]
152use cmd_browser_profile::cmd_browser_profile;
153#[cfg(feature = "collab")]
154use cmd_collab::cmd_collab;
155use cmd_config_cmd::cmd_config;
156#[cfg(feature = "git-helpers")]
157use cmd_credential_helper::cmd_credential_helper;
158#[cfg(feature = "git-helpers")]
159use cmd_diff::cmd_hook_install;
160use cmd_diff::{cmd_audit_export, cmd_compare, cmd_diff};
161use cmd_doctor::cmd_doctor;
162#[cfg(feature = "mcp")]
163use cmd_doctor::cmd_mcp_doctor;
164use cmd_exec::cmd_exec;
165#[cfg(feature = "cloud-pull-gcp")]
166use cmd_gcp_pull::cmd_gcp_pull;
167#[cfg(feature = "cloud-pull-gcp")]
168use cmd_gcp_push::cmd_gcp_push;
169use cmd_gen::{cmd_completions, cmd_completions_data, cmd_gen};
170#[cfg(feature = "git-helpers")]
171use cmd_git::cmd_git;
172use cmd_import::cmd_import;
173#[cfg(feature = "cloud-pull-keepass")]
174use cmd_keepass_pull::cmd_keepass_pull;
175#[cfg(feature = "akv-pull")]
176use cmd_kv_pull::cmd_kv_pull;
177#[cfg(feature = "akv-pull")]
178use cmd_kv_push::cmd_kv_push;
179#[cfg(feature = "mcp")]
180use cmd_mcp::cmd_mcp;
181use cmd_mobile::cmd_mobile;
182use cmd_ns::cmd_ns;
183#[cfg(feature = "plugins")]
184use cmd_plugin::cmd_plugin;
185use cmd_policy::{cmd_policy, cmd_rotate_due};
186use cmd_profile_cmd::{cmd_profile, cmd_rotate, cmd_unlock};
187#[cfg(feature = "multi-pull")]
188use cmd_pull::cmd_pull;
189#[cfg(feature = "akv-pull")]
190use cmd_push::cmd_push;
191use cmd_rotate::cmd_rotate_key;
192#[cfg(feature = "ots-sharing")]
193use cmd_share::{cmd_receive_once, cmd_share_once};
194use cmd_snapshot_cmd::cmd_snapshot;
195#[cfg(feature = "ssh")]
196use cmd_ssh::{cmd_ssh, cmd_ssh_add, cmd_ssh_import};
197#[cfg(feature = "cloud-pull-aws")]
198use cmd_ssm_pull::cmd_ssm_pull;
199#[cfg(feature = "cloud-pull-aws")]
200use cmd_ssm_push::cmd_ssm_push;
201#[cfg(feature = "git-helpers")]
202use cmd_sync::cmd_sync;
203#[cfg(feature = "team-core")]
204use cmd_team::cmd_team;
205use cmd_template::{cmd_redact, cmd_template};
206use cmd_tooling::cmd_tooling;
207use cmd_totp::{cmd_pin, cmd_qr, cmd_totp, cmd_unpin};
208use cmd_validate::cmd_validate;
209use cmd_vault::{cmd_delete, cmd_export, cmd_get, cmd_init, cmd_list, cmd_set};
210#[cfg(feature = "cloud-pull-1password")]
211use cmd_vault_pull::cmd_op_pull;
212#[cfg(feature = "cloud-pull-vault")]
213use cmd_vault_pull::cmd_vault_pull;
214
215use crate::cli::{Cli, Commands, ExecPresetSetting};
216#[cfg(feature = "mcp")]
217use crate::cli::{McpAction, McpCliAction};
218use anyhow::Result;
219use clap::Parser;
220use colored::Colorize;
221use tsafe_core::profile;
222
223pub fn run() {
231 #[cfg(feature = "otel")]
245 let _otel_provider = init_tracing();
246
247 #[cfg(not(feature = "otel"))]
248 init_tracing();
249
250 let cli = Cli::parse();
251 if let Err(e) = dispatch(cli) {
252 eprintln!("{} {e:#}", "error:".red().bold());
253 std::process::exit(GENERIC_FAILURE.code().into());
263 }
264}
265
266const GENERIC_FAILURE: axiom_exit::Exit = axiom_exit::Exit::AssertionFailed;
270
271fn dispatch(cli: Cli) -> Result<()> {
274 let profile_explicit = cli.profile.is_some();
276 let profile = cli.profile.unwrap_or_else(profile::get_default_profile);
277 if command_requires_valid_profile(&cli.command) {
278 profile::validate_profile_name(&profile)?;
279 }
280
281 match cli.command {
282 Commands::Init => cmd_init(&profile, profile_explicit),
283 Commands::Config { action } => cmd_config(action),
284 Commands::Set {
285 key,
286 value,
287 tags,
288 overwrite,
289 } => cmd_set(&profile, &key, value, tags, overwrite),
290 Commands::Get { key, copy, version } => cmd_get(&profile, &key, copy, version),
291 Commands::Delete { key } => cmd_delete(&profile, &key),
292 Commands::List { tags, ns } => cmd_list(&profile, &tags, ns.as_deref()),
293 Commands::Export {
294 format,
295 keys,
296 tags,
297 ns,
298 } => cmd_export(&profile, format, keys, tags, ns.as_deref()),
299 Commands::Exec {
300 cmd,
301 contract,
302 ns,
303 keys,
304 mode,
305 timeout,
306 preset,
307 dry_run,
308 plan,
309 no_inherit,
310 minimal,
311 only,
312 require,
313 env_mappings,
314 deny_dangerous_env,
315 allow_dangerous_env,
316 redact_output,
317 no_redact_output,
318 } => {
319 let effective_minimal = minimal || matches!(preset, Some(ExecPresetSetting::Minimal));
321 cmd_exec(
322 &profile,
323 profile_explicit,
324 contract.as_deref(),
325 cmd,
326 ns.as_deref(),
327 keys,
328 mode,
329 timeout,
330 dry_run,
331 plan,
332 no_inherit,
333 effective_minimal,
334 only,
335 require,
336 env_mappings,
337 deny_dangerous_env,
338 allow_dangerous_env,
339 redact_output,
340 no_redact_output,
341 )
342 }
343 Commands::Import {
344 from,
345 file,
346 overwrite,
347 skip_duplicates,
348 ns,
349 dry_run,
350 } => cmd_import(
351 &profile,
352 &from,
353 file.as_deref(),
354 overwrite,
355 skip_duplicates,
356 ns.as_deref(),
357 dry_run,
358 ),
359 Commands::Mobile { action } => cmd_mobile(&profile, action),
360 Commands::Ns { action } => cmd_ns(&profile, action),
361 Commands::Rotate => cmd_rotate(&profile),
362 Commands::RotateKey {
363 profile: profile_override,
364 } => {
365 let effective = profile_override.as_deref().unwrap_or(&profile);
366 cmd_rotate_key(effective)
367 }
368 Commands::Profile { action } => cmd_profile(&profile, action),
369 Commands::Audit {
370 limit,
371 hibp,
372 explain,
373 json,
374 cell_id,
375 } => cmd_audit(&profile, limit, hibp, explain, json, cell_id.as_deref()),
376 Commands::Validate {
377 cellos_policy,
378 policy_file,
379 json,
380 } => {
381 let path = cellos_policy.or(policy_file).ok_or_else(|| {
382 anyhow::anyhow!("one of --cellos-policy or --policy-file is required")
383 })?;
384 cmd_validate(&path, json)
385 }
386 Commands::Snapshot { action } => cmd_snapshot(&profile, action),
387 #[cfg(feature = "akv-pull")]
388 Commands::KvPull {
389 prefix,
390 overwrite,
391 on_error,
392 } => cmd_kv_pull(&profile, prefix.as_deref(), overwrite, on_error),
393 #[cfg(feature = "akv-pull")]
394 Commands::KvPush {
395 prefix,
396 ns,
397 dry_run,
398 yes,
399 delete_missing,
400 } => cmd_kv_push(
401 &profile,
402 prefix.as_deref(),
403 ns.as_deref(),
404 dry_run,
405 yes,
406 delete_missing,
407 ),
408 #[cfg(feature = "ots-sharing")]
409 Commands::ShareOnce { key, ttl, e2e } => cmd_share_once(&profile, &key, &ttl, e2e),
410 Commands::Gen {
411 key,
412 length,
413 charset,
414 words,
415 tags,
416 print,
417 exclude_ambiguous,
418 } => cmd_gen(
419 &profile,
420 &key,
421 length,
422 &charset,
423 words,
424 tags,
425 print,
426 exclude_ambiguous,
427 ),
428 Commands::Diff => cmd_diff(&profile),
429 Commands::Compare { profile_b } => cmd_compare(&profile, &profile_b),
430 #[cfg(feature = "git-helpers")]
431 Commands::HookInstall { dir } => cmd_hook_install(dir.as_deref()),
432 Commands::AuditExport { format, output } => {
433 cmd_audit_export(&profile, format, output.as_deref())
434 }
435 Commands::AuditVerify { json } => cmd_audit_verify(&profile, json),
436 #[cfg(feature = "cloud-pull-vault")]
437 Commands::VaultPull {
438 addr,
439 token,
440 mount,
441 prefix,
442 overwrite,
443 } => cmd_vault_pull(
444 &profile,
445 addr.as_deref(),
446 token.as_deref(),
447 mount.as_deref(),
448 prefix.as_deref(),
449 overwrite,
450 ),
451 #[cfg(feature = "cloud-pull-1password")]
452 Commands::OpPull {
453 item,
454 op_vault,
455 overwrite,
456 } => cmd_op_pull(&profile, &item, op_vault.as_deref(), overwrite),
457 #[cfg(feature = "cloud-pull-bitwarden")]
458 Commands::BwPull {
459 bw_client_id,
460 bw_client_secret,
461 bw_api_url,
462 bw_identity_url,
463 bw_folder,
464 bw_password_env,
465 overwrite,
466 on_error,
467 dry_run,
468 } => {
469 if dry_run {
470 println!("Dry run — Bitwarden pull would contact the bw CLI.");
471 println!(
472 " client_id: {}",
473 bw_client_id
474 .as_deref()
475 .unwrap_or("(from TSAFE_BW_CLIENT_ID)")
476 );
477 println!(
478 " api_url: {}",
479 bw_api_url.as_deref().unwrap_or("https://api.bitwarden.com")
480 );
481 println!(
482 " identity_url: {}",
483 bw_identity_url
484 .as_deref()
485 .unwrap_or("https://identity.bitwarden.com")
486 );
487 println!(
488 " folder: {}",
489 bw_folder.as_deref().unwrap_or("(all items)")
490 );
491 println!(
492 " password_env: {}",
493 bw_password_env.as_deref().unwrap_or("TSAFE_BW_PASSWORD")
494 );
495 println!(" overwrite: {overwrite}");
496 return Ok(());
497 }
498 cmd_bitwarden_pull(
499 &profile,
500 bw_api_url.as_deref(),
501 bw_identity_url.as_deref(),
502 bw_client_id.as_deref(),
503 bw_client_secret.as_deref(),
504 bw_folder.as_deref(),
505 bw_password_env.as_deref(),
506 overwrite,
507 on_error,
508 )
509 }
510 #[cfg(feature = "cloud-pull-keepass")]
511 Commands::KpPull {
512 kp_path,
513 kp_password_env,
514 kp_keyfile,
515 kp_group,
516 kp_recursive,
517 overwrite,
518 on_error,
519 } => {
520 use tsafe_core::pullconfig::PullSource;
521 let src = PullSource::Keepass {
522 name: None,
523 ns: None,
524 path: kp_path,
525 password_env: Some(kp_password_env),
526 keyfile_path: kp_keyfile,
527 group: kp_group,
528 recursive: Some(kp_recursive),
529 overwrite,
530 };
531 cmd_keepass_pull(&profile, &src, overwrite, on_error)
532 }
533 #[cfg(feature = "cloud-pull-aws")]
534 Commands::AwsPull {
535 region,
536 prefix,
537 overwrite,
538 on_error,
539 } => cmd_aws_pull(
540 &profile,
541 region.as_deref(),
542 prefix.as_deref(),
543 overwrite,
544 on_error,
545 ),
546 #[cfg(feature = "cloud-pull-gcp")]
547 Commands::GcpPull {
548 project,
549 prefix,
550 overwrite,
551 on_error,
552 } => cmd_gcp_pull(
553 &profile,
554 project.as_deref(),
555 prefix.as_deref(),
556 overwrite,
557 on_error,
558 ),
559 #[cfg(feature = "cloud-pull-gcp")]
560 Commands::GcpPush {
561 project,
562 prefix,
563 ns,
564 dry_run,
565 yes,
566 delete_missing,
567 } => cmd_gcp_push(
568 &profile,
569 project.as_deref(),
570 prefix.as_deref(),
571 ns.as_deref(),
572 dry_run,
573 yes,
574 delete_missing,
575 ),
576 #[cfg(feature = "cloud-pull-aws")]
577 Commands::AwsPush {
578 region,
579 prefix,
580 dry_run,
581 yes,
582 delete_missing,
583 } => cmd_aws_push(
584 &profile,
585 region.as_deref(),
586 prefix.as_deref(),
587 dry_run,
588 yes,
589 delete_missing,
590 ),
591 #[cfg(feature = "cloud-pull-aws")]
592 Commands::SsmPull {
593 region,
594 path,
595 overwrite,
596 on_error,
597 } => cmd_ssm_pull(
598 &profile,
599 region.as_deref(),
600 path.as_deref(),
601 overwrite,
602 on_error,
603 ),
604 #[cfg(feature = "cloud-pull-aws")]
605 Commands::SsmPush {
606 region,
607 path,
608 dry_run,
609 yes,
610 delete_missing,
611 } => cmd_ssm_push(
612 &profile,
613 region.as_deref(),
614 path.as_deref(),
615 dry_run,
616 yes,
617 delete_missing,
618 ),
619 Commands::Completions { shell } => cmd_completions(shell),
620 Commands::CompletionsData { data_type } => cmd_completions_data(&data_type),
621 Commands::Doctor { json } => cmd_doctor(&profile, json),
622 Commands::Explain { topic } => {
623 crate::explain::run(topic);
624 Ok(())
625 }
626 Commands::Unlock => cmd_unlock(&profile),
627 #[cfg(feature = "tui")]
628 Commands::Ui => {
629 std::env::set_var("TSAFE_CLI_VERSION", env!("CARGO_PKG_VERSION"));
632 tsafe_tui::run().map_err(|e| anyhow::anyhow!(e))
633 }
634 Commands::Qr { key } => cmd_qr(&profile, &key),
635 Commands::Totp { action } => cmd_totp(&profile, action),
636 Commands::Pin { key } => cmd_pin(&profile, &key),
637 Commands::Unpin { key } => cmd_unpin(&profile, &key),
638 Commands::Alias {
639 target_key,
640 alias_name,
641 list,
642 } => cmd_alias(&profile, target_key.as_deref(), alias_name.as_deref(), list),
643 #[cfg(feature = "browser")]
644 Commands::BrowserProfile { action } => cmd_browser_profile(&profile, action),
645 #[cfg(feature = "nativehost")]
646 Commands::BrowserNativeHost { action } => cmd_browser_native_host(action),
647 #[cfg(feature = "ots-sharing")]
648 Commands::ReceiveOnce { url, store, e2e } => {
649 cmd_receive_once(&profile, &url, store.as_deref(), e2e)
650 }
651 #[cfg(feature = "agent")]
652 Commands::Agent { action } => cmd_agent(&profile, action),
653 #[cfg(feature = "mcp")]
654 Commands::Mcp { action } => cmd_mcp_cli(&profile, action),
655 #[cfg(feature = "git-helpers")]
656 Commands::Git { args } => cmd_git(&profile, args),
657 Commands::Attest { action } => cmd_attest(&profile, action),
658 Commands::History { key } => cmd_history(&profile, &key),
659 Commands::Mv {
660 source,
661 dest,
662 to_profile,
663 force,
664 } => cmd_mv(
665 &profile,
666 &source,
667 dest.as_deref(),
668 to_profile.as_deref(),
669 force,
670 ),
671 Commands::Policy { action } => cmd_policy(&profile, action),
672 Commands::RotateDue { json, fail } => cmd_rotate_due(&profile, json, fail),
673 #[cfg(feature = "ssh")]
674 Commands::SshAdd { key } => cmd_ssh_add(&profile, &key),
675 #[cfg(feature = "ssh")]
676 Commands::SshImport { path, name, tags } => {
677 cmd_ssh_import(&profile, &path, name.as_deref(), tags)
678 }
679 #[cfg(feature = "ssh")]
680 Commands::Ssh { action } => cmd_ssh(&profile, action),
681 #[cfg(feature = "multi-pull")]
682 Commands::Pull {
683 config,
684 overwrite,
685 on_error,
686 dry_run,
687 sources,
688 } => cmd_pull(
689 &profile,
690 config.as_deref(),
691 overwrite,
692 on_error,
693 dry_run,
694 &sources,
695 ),
696 #[cfg(feature = "akv-pull")]
697 Commands::Push {
698 config,
699 source,
700 dry_run,
701 yes,
702 delete_missing,
703 on_error,
704 } => cmd_push(
705 &profile,
706 config.as_deref(),
707 &source,
708 dry_run,
709 yes,
710 delete_missing,
711 on_error,
712 ),
713 #[cfg(feature = "biometric")]
714 Commands::Biometric { action } => cmd_biometric(&profile, action),
715 #[cfg(feature = "team-core")]
716 Commands::Team { action } => cmd_team(&profile, action),
717 #[cfg(feature = "git-helpers")]
718 Commands::Sync {
719 remote,
720 branch,
721 file,
722 dry_run,
723 } => cmd_sync(&profile, &remote, &branch, file.as_deref(), dry_run),
724 Commands::Template {
725 file,
726 output,
727 ignore_missing,
728 } => cmd_template(&profile, &file, output.as_deref(), ignore_missing),
729 Commands::Redact => cmd_redact(&profile),
730 Commands::Tooling { action } => cmd_tooling(action),
731 Commands::BuildInfo { json } => cmd_build_info(json),
732 #[cfg(feature = "plugins")]
733 Commands::Plugin { tool, args } => cmd_plugin(&profile, tool.as_deref(), &args),
734 #[cfg(feature = "git-helpers")]
735 Commands::CredentialHelper { action, global } => {
736 cmd_credential_helper(&profile, action, global)
737 }
738 #[cfg(feature = "collab")]
739 Commands::Collab { action } => cmd_collab(&profile, action),
740 }
741}
742
743#[cfg(feature = "mcp")]
744fn cmd_mcp_cli(profile: &str, action: McpCliAction) -> Result<()> {
745 let action = match action {
746 McpCliAction::Serve {
747 allowed_keys,
748 denied_keys,
749 contract,
750 workdir,
751 allow_reveal,
752 audit_source,
753 } => McpAction::Serve {
754 allowed_keys,
755 denied_keys,
756 contract,
757 workdir,
758 allow_reveal,
759 audit_source,
760 },
761 McpCliAction::Install {
762 host,
763 name,
764 global,
765 project,
766 dry_run,
767 allowed_keys,
768 denied_keys,
769 contract,
770 workdir,
771 allow_reveal,
772 } => McpAction::Install {
773 host,
774 name,
775 global,
776 project,
777 dry_run,
778 allowed_keys,
779 denied_keys,
780 contract,
781 workdir,
782 allow_reveal,
783 },
784 McpCliAction::Config {
785 host,
786 name,
787 contract,
788 workdir,
789 } => McpAction::Config {
790 host,
791 name,
792 contract,
793 workdir,
794 },
795 McpCliAction::Uninstall { host, name } => McpAction::Uninstall { host, name },
796 McpCliAction::Status => McpAction::Status,
797 McpCliAction::Doctor {
798 code,
799 contract,
800 workdir,
801 receipt_id,
802 json,
803 } => {
804 return cmd_mcp_doctor(
805 profile,
806 &code,
807 &contract,
808 &workdir,
809 receipt_id.as_deref(),
810 json,
811 );
812 }
813 };
814
815 cmd_mcp(profile, action)
816}
817
818fn command_requires_valid_profile(command: &Commands) -> bool {
819 let skips_profile_validation = matches!(
820 command,
821 Commands::BuildInfo { .. }
822 | Commands::Completions { .. }
823 | Commands::CompletionsData { .. }
824 | Commands::Config { .. }
825 | Commands::Explain { .. }
826 | Commands::Tooling { .. }
827 | Commands::Validate { .. }
828 | Commands::Attest { .. }
829 );
830
831 #[cfg(feature = "tui")]
832 let skips_profile_validation = skips_profile_validation || matches!(command, Commands::Ui);
833
834 #[cfg(feature = "nativehost")]
835 let skips_profile_validation =
836 skips_profile_validation || matches!(command, Commands::BrowserNativeHost { .. });
837
838 !skips_profile_validation
839}
840
841fn compile_time_feature_flags() -> Vec<&'static str> {
842 let mut feature_flags = Vec::new();
843
844 if cfg!(feature = "tui") {
845 feature_flags.push("tui");
846 }
847 if cfg!(feature = "akv-pull") {
848 feature_flags.push("akv-pull");
849 }
850 if cfg!(feature = "biometric") {
851 feature_flags.push("biometric");
852 }
853 if cfg!(feature = "agent") {
854 feature_flags.push("agent");
855 }
856 if cfg!(feature = "mcp") {
857 feature_flags.push("mcp");
858 }
859 if cfg!(feature = "team-core") {
860 feature_flags.push("team-core");
861 }
862 if cfg!(feature = "cloud-pull-aws") {
863 feature_flags.push("cloud-pull-aws");
864 }
865 if cfg!(feature = "cloud-pull-gcp") {
866 feature_flags.push("cloud-pull-gcp");
867 }
868 if cfg!(feature = "cloud-pull-vault") {
869 feature_flags.push("cloud-pull-vault");
870 }
871 if cfg!(feature = "cloud-pull-1password") {
872 feature_flags.push("cloud-pull-1password");
873 }
874 if cfg!(feature = "cloud-pull-keepass") {
875 feature_flags.push("cloud-pull-keepass");
876 }
877 if cfg!(feature = "cloud-pull-bitwarden") {
878 feature_flags.push("cloud-pull-bitwarden");
879 }
880 if cfg!(feature = "multi-pull") {
881 feature_flags.push("multi-pull");
882 }
883 if cfg!(feature = "pm-import-extended") {
884 feature_flags.push("pm-import-extended");
885 }
886 if cfg!(feature = "ots-sharing") {
887 feature_flags.push("ots-sharing");
888 }
889 if cfg!(feature = "git-helpers") {
890 feature_flags.push("git-helpers");
891 }
892 if cfg!(feature = "browser") {
893 feature_flags.push("browser");
894 }
895 if cfg!(feature = "nativehost") {
896 feature_flags.push("nativehost");
897 }
898 if cfg!(feature = "ssh") {
899 feature_flags.push("ssh");
900 }
901 if cfg!(feature = "plugins") {
902 feature_flags.push("plugins");
903 }
904 if cfg!(feature = "otel") {
905 feature_flags.push("otel");
906 }
907
908 feature_flags.sort_unstable();
909 feature_flags
910}
911
912const DEFAULT_CORE_BUILD_PROFILE: &[&str] = &[
913 "agent",
914 "akv-pull",
915 "biometric",
916 "mcp",
917 "ssh",
918 "team-core",
919 "tui",
920];
921
922fn build_profile_label(capabilities: &[&'static str]) -> &'static str {
923 if capabilities.is_empty() {
924 "enterprise-minimal"
925 } else if capabilities == DEFAULT_CORE_BUILD_PROFILE {
926 "default-core"
927 } else {
928 "custom"
929 }
930}
931
932fn cmd_build_info(json: bool) -> Result<()> {
933 let capabilities = compile_time_feature_flags();
934 let profile = build_profile_label(&capabilities);
935
936 if json {
937 let payload = serde_json::json!({
938 "build_profile": profile,
939 "capabilities": capabilities,
940 });
941 println!("{}", serde_json::to_string_pretty(&payload)?);
942 } else {
943 println!("build_profile: {profile}");
944 if capabilities.is_empty() {
945 println!("capabilities: none");
946 } else {
947 println!("capabilities: {}", capabilities.join(","));
948 }
949 }
950
951 Ok(())
952}
953
954fn tracing_log_enabled() -> bool {
968 std::env::var("TSAFE_LOG")
969 .ok()
970 .filter(|v| !v.is_empty())
971 .is_some()
972}
973
974fn tracing_json_enabled() -> bool {
975 std::env::var("TSAFE_LOG_FORMAT")
976 .map(|v| v.eq_ignore_ascii_case("json"))
977 .unwrap_or(false)
978}
979
980#[cfg(not(feature = "otel"))]
981fn init_tracing() {
982 if !tracing_log_enabled() {
983 return;
984 }
985
986 use tracing_subscriber::fmt::format::FmtSpan;
987 use tracing_subscriber::layer::SubscriberExt as _;
988 use tracing_subscriber::util::SubscriberInitExt as _;
989 use tracing_subscriber::Layer as _;
990 use tracing_subscriber::{fmt, EnvFilter};
991
992 let filter = EnvFilter::from_env("TSAFE_LOG");
993 let fmt_layer = if tracing_json_enabled() {
994 fmt::layer()
995 .with_writer(std::io::stderr)
996 .with_target(false)
997 .json()
998 .with_span_events(FmtSpan::CLOSE)
999 .boxed()
1000 } else {
1001 fmt::layer()
1002 .with_writer(std::io::stderr)
1003 .with_target(false)
1004 .compact()
1005 .boxed()
1006 };
1007
1008 tracing_subscriber::registry()
1009 .with(filter)
1010 .with(fmt_layer)
1011 .init();
1012}
1013
1014#[cfg(feature = "otel")]
1015fn otel_stdout_enabled() -> bool {
1016 std::env::var("TSAFE_OTEL_STDOUT")
1017 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1018 .unwrap_or(false)
1019}
1020
1021#[cfg(feature = "otel")]
1022fn otel_trace_endpoint() -> Option<String> {
1023 std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
1024 .ok()
1025 .filter(|value| !value.trim().is_empty())
1026 .or_else(|| {
1027 std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
1028 .ok()
1029 .filter(|value| !value.trim().is_empty())
1030 })
1031}
1032
1033#[cfg(feature = "otel")]
1034fn build_otel_provider() -> Option<opentelemetry_sdk::trace::SdkTracerProvider> {
1035 use opentelemetry::trace::TracerProvider as _;
1036 use opentelemetry_otlp::{Protocol, WithExportConfig};
1037 use opentelemetry_sdk::trace::SdkTracerProvider;
1038
1039 let stdout_enabled = otel_stdout_enabled();
1040 let otlp_endpoint = otel_trace_endpoint();
1041 if !stdout_enabled && otlp_endpoint.is_none() {
1042 return None;
1043 }
1044
1045 let mut builder = SdkTracerProvider::builder();
1046 let mut has_exporter = false;
1047
1048 if stdout_enabled {
1049 builder = builder.with_simple_exporter(opentelemetry_stdout::SpanExporter::default());
1050 has_exporter = true;
1051 }
1052
1053 if let Some(endpoint) = otlp_endpoint {
1054 let exporter = opentelemetry_otlp::SpanExporter::builder()
1055 .with_http()
1056 .with_endpoint(endpoint)
1057 .with_protocol(Protocol::HttpBinary)
1058 .build();
1059 match exporter {
1060 Ok(exporter) => {
1061 builder = builder.with_batch_exporter(exporter);
1062 has_exporter = true;
1063 }
1064 Err(err) => {
1065 eprintln!(
1066 "{} could not initialize OTLP HTTP exporter: {err}",
1067 "warn:".yellow()
1068 );
1069 }
1070 }
1071 }
1072
1073 if !has_exporter {
1074 return None;
1075 }
1076
1077 let provider = builder.build();
1078
1079 opentelemetry::global::set_tracer_provider(provider.clone());
1080 let _ = provider.tracer("tsafe");
1081
1082 Some(provider)
1083}
1084
1085#[cfg(feature = "otel")]
1086fn init_tracing() -> Option<opentelemetry_sdk::trace::SdkTracerProvider> {
1087 use opentelemetry::trace::TracerProvider as _;
1088 use tracing_subscriber::fmt::format::FmtSpan;
1089 use tracing_subscriber::layer::SubscriberExt as _;
1090 use tracing_subscriber::util::SubscriberInitExt as _;
1091 use tracing_subscriber::Layer as _;
1092 use tracing_subscriber::{fmt, EnvFilter};
1093
1094 let otel_provider = build_otel_provider();
1095 let log_enabled = tracing_log_enabled();
1096
1097 if log_enabled {
1098 let filter = EnvFilter::from_env("TSAFE_LOG");
1099 let otel_layer = otel_provider.as_ref().map(|provider| {
1100 tracing_opentelemetry::layer()
1101 .with_tracer(provider.tracer("tsafe"))
1102 .boxed()
1103 });
1104 let fmt_layer = if tracing_json_enabled() {
1105 fmt::layer()
1106 .with_writer(std::io::stderr)
1107 .with_target(false)
1108 .json()
1109 .with_span_events(FmtSpan::CLOSE)
1110 .boxed()
1111 } else {
1112 fmt::layer()
1113 .with_writer(std::io::stderr)
1114 .with_target(false)
1115 .compact()
1116 .boxed()
1117 };
1118
1119 tracing_subscriber::registry()
1120 .with(filter)
1121 .with(fmt_layer)
1122 .with(otel_layer)
1123 .init();
1124 } else if let Some(provider) = otel_provider.as_ref() {
1125 let tracer = provider.tracer("tsafe");
1126 tracing_subscriber::registry()
1127 .with(tracing_opentelemetry::layer().with_tracer(tracer))
1128 .init();
1129 }
1130
1131 otel_provider
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137
1138 #[test]
1139 fn build_profile_label_marks_empty_build_as_enterprise_minimal() {
1140 assert_eq!(build_profile_label(&[]), "enterprise-minimal");
1141 }
1142
1143 #[test]
1144 fn build_profile_label_marks_core_bundle_as_default_core() {
1145 assert_eq!(
1146 build_profile_label(DEFAULT_CORE_BUILD_PROFILE),
1147 "default-core"
1148 );
1149 }
1150
1151 #[test]
1152 fn build_profile_label_marks_extra_opt_in_capabilities_as_custom() {
1153 let capabilities = [
1154 "agent",
1155 "akv-pull",
1156 "biometric",
1157 "nativehost",
1158 "team-core",
1159 "tui",
1160 ];
1161 assert_eq!(build_profile_label(&capabilities), "custom");
1162 }
1163}
1164
1165#[cfg(all(test, feature = "otel"))]
1166mod otel_tests {
1167 use super::*;
1168
1169 #[test]
1170 fn otel_trace_endpoint_prefers_traces_specific_env() {
1171 temp_env::with_vars(
1172 [
1173 (
1174 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
1175 Some(std::ffi::OsStr::new("http://localhost:4318/v1/traces")),
1176 ),
1177 (
1178 "OTEL_EXPORTER_OTLP_ENDPOINT",
1179 Some(std::ffi::OsStr::new("http://localhost:4318")),
1180 ),
1181 ],
1182 || {
1183 assert_eq!(
1184 otel_trace_endpoint().as_deref(),
1185 Some("http://localhost:4318/v1/traces")
1186 );
1187 },
1188 );
1189 }
1190
1191 #[test]
1192 fn otel_trace_endpoint_falls_back_to_base_endpoint() {
1193 temp_env::with_vars(
1194 [
1195 ("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None),
1196 (
1197 "OTEL_EXPORTER_OTLP_ENDPOINT",
1198 Some(std::ffi::OsStr::new("http://localhost:4318")),
1199 ),
1200 ],
1201 || {
1202 assert_eq!(
1203 otel_trace_endpoint().as_deref(),
1204 Some("http://localhost:4318")
1205 );
1206 },
1207 );
1208 }
1209
1210 #[test]
1211 fn otel_stdout_enabled_accepts_boolean_forms() {
1212 temp_env::with_var("TSAFE_OTEL_STDOUT", Some("true"), || {
1213 assert!(otel_stdout_enabled());
1214 });
1215 temp_env::with_var("TSAFE_OTEL_STDOUT", Some("1"), || {
1216 assert!(otel_stdout_enabled());
1217 });
1218 temp_env::with_var("TSAFE_OTEL_STDOUT", Some("0"), || {
1219 assert!(!otel_stdout_enabled());
1220 });
1221 }
1222
1223 #[test]
1238 fn otel_span_fields_do_not_include_secret_bearing_names() {
1239 let safe_span_fields = [
1254 "m_cost",
1255 "t_cost",
1256 "p_cost",
1257 "plaintext_len",
1258 "ciphertext_len",
1259 "secrets",
1260 "secret_count",
1261 ];
1262
1263 let secret_bearing_names = [
1265 "password",
1266 "new_password",
1267 "salt",
1268 "key",
1269 "value",
1270 "plaintext",
1271 "ciphertext",
1272 "secret",
1273 ];
1274
1275 for safe_field in &safe_span_fields {
1276 for secret_name in &secret_bearing_names {
1277 assert_ne!(
1278 safe_field, secret_name,
1279 "span field '{safe_field}' must not be a secret-bearing parameter name"
1280 );
1281 }
1282 }
1283 }
1284}