Skip to main content

tsafe_cli/
lib.rs

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// Re-export the 1Password field-label normalisation function so that
17// integration tests can verify the mapping contract without shelling out
18// to `op`.
19#[cfg(any(feature = "cloud-pull-1password", feature = "cloud-pull-vault", test))]
20pub use op_mapping::op_field_label_to_key;
21
22// Always-compiled module that holds the pure mapping logic so it is
23// available for `#[cfg(test)]` regardless of feature flags.
24pub mod op_mapping {
25    /// Normalise a 1Password field label to an env-style vault key.
26    ///
27    /// Rule: spaces and hyphens → underscores, then uppercase.
28    /// The 1Password item name is **not** included in the output key.
29    ///
30    /// Examples:
31    /// - `"My Secret"` → `"MY_SECRET"`
32    /// - `"db-password"` → `"DB_PASSWORD"`
33    /// - `"API_KEY"` → `"API_KEY"`
34    pub fn op_field_label_to_key(label: &str) -> String {
35        label.replace([' ', '-'], "_").to_uppercase()
36    }
37}
38
39// ── Sub-command modules ───────────────────────────────────────────────────────
40// Declared here (lib root) so both the binary's main.rs and the meta-crate
41// shim can reach them through the library crate.
42
43#[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// ── Command dispatch ──────────────────────────────────────────────────────────
117
118#[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
198// ── Entry point ───────────────────────────────────────────────────────────────
199
200/// Launch the tsafe CLI.
201///
202/// Initialises tracing/OTel (if enabled), parses `std::env::args()` via clap,
203/// dispatches to the appropriate sub-command handler, and exits with code 1 on
204/// error.  Called from `main.rs` and from the tsafe meta-crate's bin shim.
205pub fn run() {
206    // Structured logging via tracing. Controlled by TSAFE_LOG env var:
207    //   TSAFE_LOG=debug   — verbose debug output to stderr
208    //   TSAFE_LOG=info    — informational messages only
209    //   (unset)           — logging disabled (zero overhead)
210    // Optional: TSAFE_LOG_FORMAT=json — newline-delimited JSON on stderr (CI / log aggregators).
211    // JSON mode enables span-close events so `#[instrument]`d calls (e.g. vault open, KDF) emit lines.
212    //
213    // Feature-gated: when compiled with `--features otel`, OpenTelemetry can be added
214    // on top of the same subscriber via either:
215    //   TSAFE_OTEL_STDOUT=1              — stdout exporter (debug/local)
216    //   OTEL_EXPORTER_OTLP_ENDPOINT=...  — OTLP HTTP exporter
217    //   OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... — traces-specific OTLP HTTP endpoint
218    // See docs/features/opentelemetry.md for full configuration.
219    #[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
232// ── dispatch ─────────────────────────────────────────────────────────────────
233
234fn dispatch(cli: Cli) -> Result<()> {
235    // Resolve active profile: explicit -p flag > TSAFE_PROFILE env > persisted default > "default"
236    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            // --preset minimal is equivalent to --minimal; --preset full is the default.
281            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            // Inject the CLI binary version so the TUI version badge shows
589            // the installed binary version rather than the tsafe-core version.
590            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
832// ── Tracing / OpenTelemetry feature gate ─────────────────────────────────────
833//
834// Compiled only when `--features otel` is passed. Returns the provider so the
835// caller can hold it alive until program exit (Drop flushes the span pipeline).
836//
837// Environment variables:
838//   TSAFE_LOG=debug/info               — stderr structured logging
839//   TSAFE_LOG_FORMAT=json             — JSON stderr logging
840//   TSAFE_OTEL_STDOUT=1               — emit OTel spans to stdout
841//   OTEL_EXPORTER_OTLP_ENDPOINT=...   — OTLP HTTP exporter endpoint
842//   OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... — traces-specific OTLP HTTP endpoint
843// See docs/features/opentelemetry.md for details.
844
845fn 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    // ── Task 6.3: no-secret-in-span invariant (ADR-024) ───────────────────
1102    //
1103    // OTel spans must never contain plaintext secret values or plaintext key
1104    // names. This test verifies that the span fields produced by instrumented
1105    // code in tsafe-core use `skip(password, ...)`, `skip(key, ...)`, and
1106    // `skip(value, ...)` attributes and that no secret-bearing parameter name
1107    // appears as a span field. The invariant applies with equal force to OTel
1108    // spans as to CloudEvents (ADR-024).
1109    //
1110    // This is a static verification test: it checks the known span field names
1111    // that the `#[instrument]` macros in tsafe-core actually record. If a new
1112    // `#[instrument]` call is added that records a secret-bearing field, this
1113    // test must be updated to demonstrate the field is safe.
1114
1115    #[test]
1116    fn otel_span_fields_do_not_include_secret_bearing_names() {
1117        // These are the known span field names from `#[instrument]` calls in
1118        // tsafe-core. Fields that could carry secret material are explicitly
1119        // skipped via `skip(...)` in the macro invocation.
1120        //
1121        // derive_key:    #[instrument(skip(password, salt), fields(m_cost, t_cost, p_cost))]
1122        // aes_encrypt:   #[instrument(skip_all, fields(plaintext_len = plaintext.len()))]
1123        // aes_decrypt:   #[instrument(skip_all, fields(ciphertext_len = ciphertext.len()))]
1124        // Vault::open:   #[instrument(skip(password, path))]
1125        // Vault::save:   #[instrument(skip(password, path))]
1126        // Vault::set:    #[instrument(skip(self, value, tags, key))]
1127        // Vault::delete: #[instrument(skip(self, key))]
1128        // Vault::rotate: #[instrument(skip(self, new_password), fields(secret_count = ...))]
1129
1130        // The span fields that are actually recorded (not skipped):
1131        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        // None of these should ever be a secret-bearing parameter name.
1142        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}