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