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