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