Skip to main content

rtb_cli/
credentials.rs

1//! `credentials` CLI subtree — `list / add / remove / test / doctor`.
2//!
3//! Backed by `App::credentials_provider` (see
4//! [`rtb_app::credentials::CredentialProvider`]) and
5//! `rtb-credentials`'s [`Resolver`] / [`KeyringStore`]. Honours the
6//! global `--output text|json` flag for the structured-data leaves
7//! (`list`, `test`, `doctor`); `add` and `remove` are interactive
8//! and ignore it.
9//!
10//! # Lint exception
11//!
12//! `linkme::distributed_slice` emits `#[link_section]` which Rust
13//! 1.95+ flags under `unsafe_code`. Allowed at module level — no
14//! hand-rolled `unsafe` blocks anywhere in the module.
15
16#![allow(unsafe_code)]
17
18use std::ffi::OsString;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use clap::{Parser, Subcommand};
23use linkme::distributed_slice;
24use miette::miette;
25use rtb_app::app::App;
26use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
27use rtb_app::features::Feature;
28use rtb_credentials::{
29    CredentialError, CredentialRef, CredentialStore, KeyringStore, ResolutionOutcome,
30    ResolutionSource, Resolver, SecretString,
31};
32use serde::Serialize;
33use tabled::Tabled;
34
35use crate::render::{output, strip_global_output, OutputMode};
36
37/// The `credentials` subcommand.
38pub struct CredentialsCmd;
39
40#[async_trait]
41impl Command for CredentialsCmd {
42    fn spec(&self) -> &CommandSpec {
43        static SPEC: CommandSpec = CommandSpec {
44            name: "credentials",
45            about: "Manage credential storage (list / add / remove / test / doctor)",
46            aliases: &[],
47            feature: Some(Feature::Credentials),
48        };
49        &SPEC
50    }
51
52    fn subcommand_passthrough(&self) -> bool {
53        true
54    }
55
56    async fn run(&self, app: App) -> miette::Result<()> {
57        let mut args: Vec<OsString> = std::env::args_os().collect();
58        if args.len() >= 2 {
59            args.drain(..2);
60        }
61        args.insert(0, OsString::from("credentials"));
62        // The global `--output` flag is parsed once via
63        // `OutputMode::from_args_os` above; strip it here so the
64        // inner clap parser doesn't reject it as unknown. clap's
65        // outer `global = true` propagation works for normal
66        // subcommands, but `subcommand_passthrough = true` captures
67        // post-name tokens as `trailing_var_arg`, so the global
68        // never reaches the outer parser for this subtree.
69        args = strip_global_output(args);
70
71        let cli = match CredentialsCli::try_parse_from(args) {
72            Ok(c) => c,
73            Err(e) => {
74                use clap::error::ErrorKind;
75                if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
76                    print!("{e}");
77                    return Ok(());
78                }
79                return Err(miette!("{e}"));
80            }
81        };
82
83        let mode = OutputMode::from_args_os();
84        match cli.command {
85            CredentialsSub::List => run_list(&app, mode),
86            CredentialsSub::Add { name } => run_add(&app, &name).await,
87            CredentialsSub::Remove { name } => run_remove(&app, &name).await,
88            CredentialsSub::Test { name } => run_test(&app, &name, mode).await,
89            CredentialsSub::Doctor => run_doctor(&app, mode).await,
90        }
91    }
92}
93
94#[distributed_slice(BUILTIN_COMMANDS)]
95fn __register_credentials() -> Box<dyn Command> {
96    Box::new(CredentialsCmd)
97}
98
99// ---------------------------------------------------------------------
100// clap surface
101// ---------------------------------------------------------------------
102
103#[derive(Debug, Parser)]
104#[command(name = "credentials", about = "Manage credential storage")]
105struct CredentialsCli {
106    #[command(subcommand)]
107    command: CredentialsSub,
108}
109
110#[derive(Debug, Subcommand)]
111enum CredentialsSub {
112    /// List every declared credential and the resolver's view of it.
113    List,
114    /// Add or overwrite a credential through an interactive wizard.
115    Add {
116        /// Name of the credential as registered via the
117        /// `CredentialBearing` impl on the tool's typed config.
118        name: String,
119    },
120    /// Remove a keychain-stored credential.
121    Remove {
122        /// Name of the credential.
123        name: String,
124    },
125    /// Probe a credential's resolution and print what hit.
126    Test {
127        /// Name of the credential.
128        name: String,
129    },
130    /// Aggregate `test` calls for every declared credential.
131    Doctor,
132}
133
134// ---------------------------------------------------------------------
135// `list`
136// ---------------------------------------------------------------------
137
138#[derive(Tabled, Serialize)]
139struct ListRow {
140    name: String,
141    service: String,
142    account: String,
143    mode: &'static str,
144    status: &'static str,
145}
146
147fn run_list(app: &App, mode: OutputMode) -> miette::Result<()> {
148    let creds = app.credentials();
149    let rows: Vec<ListRow> = creds
150        .iter()
151        .map(|(name, cref)| {
152            let (service, account) = cref.keychain.as_ref().map_or_else(
153                || ("-".to_string(), "-".to_string()),
154                |k| (k.service.clone(), k.account.clone()),
155            );
156            // `list` is a config-shape inspection; report the
157            // first-configured layer rather than running the full
158            // resolver probe (which can hit the keychain). `doctor`
159            // is the leaf that does I/O.
160            let mode_str = first_configured_layer(cref);
161            ListRow { name: name.clone(), service, account, mode: mode_str, status: "-" }
162        })
163        .collect();
164    output(mode, &rows).map_err(|e| miette!("render: {e}"))
165}
166
167const fn first_configured_layer(cref: &CredentialRef) -> &'static str {
168    if cref.env.is_some() {
169        "env"
170    } else if cref.keychain.is_some() {
171        "keychain"
172    } else if cref.literal.is_some() {
173        "literal"
174    } else if cref.fallback_env.is_some() {
175        "fallback-env"
176    } else {
177        "unconfigured"
178    }
179}
180
181// ---------------------------------------------------------------------
182// `add`
183// ---------------------------------------------------------------------
184
185async fn run_add(app: &App, name: &str) -> miette::Result<()> {
186    let cred = lookup_credential(app, name)?;
187
188    // C5 resolution — refuse a literal-only ref. Adding a layer the
189    // config doesn't declare invites resolve-time surprises.
190    let only_literal = cred.literal.is_some()
191        && cred.env.is_none()
192        && cred.keychain.is_none()
193        && cred.fallback_env.is_none();
194    if only_literal {
195        return Err(miette!(
196            help = "edit your config to add an `env` or `keychain` layer first",
197            "credential `{name}` declares only a literal layer; refusing to add an undeclared override"
198        ));
199    }
200
201    // Interactive wizard: pick storage mode, then capture the secret.
202    let mode = match cred.keychain.as_ref() {
203        Some(_) => Some(StorageChoice::Keychain),
204        None => Some(StorageChoice::Env),
205    };
206    let storage = mode.ok_or_else(|| miette!("credential `{name}` declares no settable layer"))?;
207
208    match storage {
209        StorageChoice::Env => {
210            let var = cred.env.as_deref().ok_or_else(|| {
211                miette!(
212                    "credential `{name}` has no env layer to populate; declare it in config first"
213                )
214            })?;
215            let secret = prompt_secret(name).map_err(|e| miette!("prompt: {e}"))?;
216            // SAFETY: This is the operator's interactive shell — we
217            // surface the right env-var name and exit. Storing the
218            // secret in the running process's environment would make
219            // it visible to child processes; instead, instruct the
220            // operator to export the var themselves.
221            let _ = secret; // The wizard captured it; we don't echo it.
222            println!("set the secret in your shell:");
223            println!("    export {var}=...");
224            println!("(rtb-cli does not write to the calling shell's environment.)");
225            Ok(())
226        }
227        StorageChoice::Keychain => {
228            let keyref = cred.keychain.as_ref().ok_or_else(|| {
229                miette!("credential `{name}` has no keychain layer; declare it in config first")
230            })?;
231            let secret = prompt_secret(name).map_err(|e| miette!("prompt: {e}"))?;
232            let store = KeyringStore::new();
233            store
234                .set(&keyref.service, &keyref.account, secret)
235                .await
236                .map_err(|e| miette!("keychain set: {e}"))?;
237            println!(
238                "stored credential `{name}` in keychain (service=`{}`, account=`{}`)",
239                keyref.service, keyref.account
240            );
241            Ok(())
242        }
243    }
244}
245
246#[derive(Debug, Clone, Copy)]
247enum StorageChoice {
248    Env,
249    Keychain,
250}
251
252/// Capture a secret without echoing. Uses `inquire::Password` —
253/// already a transitive dep through `rtb-tui`.
254fn prompt_secret(name: &str) -> Result<SecretString, inquire::InquireError> {
255    let raw = inquire::Password::new(&format!("Secret for `{name}`:"))
256        .with_display_mode(inquire::PasswordDisplayMode::Masked)
257        .without_confirmation()
258        .prompt()?;
259    Ok(SecretString::from(raw))
260}
261
262// ---------------------------------------------------------------------
263// `remove`
264// ---------------------------------------------------------------------
265
266async fn run_remove(app: &App, name: &str) -> miette::Result<()> {
267    let cred = lookup_credential(app, name)?;
268    if cred.literal.is_some() && cred.keychain.is_none() {
269        // C1 resolution — hard failure on literal-only refs.
270        return Err(miette!(
271            help = "edit your config file to remove the literal value",
272            "credential `{name}` is a literal in config; rtb-cli refuses to silently leave it in place"
273        ));
274    }
275    let keyref = cred
276        .keychain
277        .as_ref()
278        .ok_or_else(|| miette!("credential `{name}` has no keychain layer to remove from"))?;
279    let store = KeyringStore::new();
280    match store.delete(&keyref.service, &keyref.account).await {
281        Ok(()) => {
282            println!("removed `{name}` from keychain");
283            Ok(())
284        }
285        Err(CredentialError::NotFound { .. }) => {
286            println!("credential `{name}` not present in keychain (nothing to remove)");
287            Ok(())
288        }
289        Err(e) => Err(miette!("keychain delete: {e}")),
290    }
291}
292
293// ---------------------------------------------------------------------
294// `test`
295// ---------------------------------------------------------------------
296
297#[derive(Tabled, Serialize)]
298struct TestRow {
299    name: String,
300    source: &'static str,
301    status: &'static str,
302}
303
304async fn run_test(app: &App, name: &str, mode: OutputMode) -> miette::Result<()> {
305    let cred = lookup_credential(app, name)?;
306    let resolver = Resolver::with_platform_default();
307    let row = probe_to_row(name, &cred, &resolver).await;
308    let failed = row.status != "resolved";
309    output(mode, &[row]).map_err(|e| miette!("render: {e}"))?;
310    if failed {
311        return Err(miette!("credential `{name}` did not resolve"));
312    }
313    Ok(())
314}
315
316// ---------------------------------------------------------------------
317// `doctor`
318// ---------------------------------------------------------------------
319
320async fn run_doctor(app: &App, mode: OutputMode) -> miette::Result<()> {
321    let resolver = Resolver::with_platform_default();
322    let mut rows = Vec::new();
323    let mut any_failed = false;
324    for (name, cref) in app.credentials() {
325        let row = probe_to_row(&name, &cref, &resolver).await;
326        if row.status != "resolved" {
327            any_failed = true;
328        }
329        rows.push(row);
330    }
331    output(mode, &rows).map_err(|e| miette!("render: {e}"))?;
332    if any_failed {
333        return Err(miette!("one or more credentials did not resolve"));
334    }
335    Ok(())
336}
337
338async fn probe_to_row(name: &str, cref: &CredentialRef, resolver: &Resolver) -> TestRow {
339    match resolver.probe(cref).await {
340        Ok(ResolutionOutcome::Resolved(source)) => {
341            TestRow { name: name.to_string(), source: source_label(source), status: "resolved" }
342        }
343        Ok(ResolutionOutcome::LiteralRefusedInCi) => {
344            TestRow { name: name.to_string(), source: "literal", status: "refused-in-ci" }
345        }
346        Ok(ResolutionOutcome::Missing) => {
347            TestRow { name: name.to_string(), source: "-", status: "missing" }
348        }
349        // `Resolver::probe` returns `Err` only for keychain backend
350        // failures (locked store, OS-level error). `ResolutionOutcome`
351        // is `#[non_exhaustive]` so `_` covers any future variant
352        // additions.
353        Ok(_) => TestRow { name: name.to_string(), source: "-", status: "unknown" },
354        Err(_) => TestRow { name: name.to_string(), source: "-", status: "error" },
355    }
356}
357
358const fn source_label(s: ResolutionSource) -> &'static str {
359    match s {
360        ResolutionSource::Env => "env",
361        ResolutionSource::Keychain => "keychain",
362        ResolutionSource::Literal => "literal",
363        ResolutionSource::FallbackEnv => "fallback-env",
364        // `ResolutionSource` is `#[non_exhaustive]`; defensive arm
365        // for future additions.
366        _ => "unknown",
367    }
368}
369
370// ---------------------------------------------------------------------
371// shared
372// ---------------------------------------------------------------------
373
374fn lookup_credential(app: &App, name: &str) -> miette::Result<Arc<CredentialRef>> {
375    app.credentials().into_iter().find(|(n, _)| n == name).map(|(_, c)| Arc::new(c)).ok_or_else(
376        || {
377            let known: Vec<String> = app.credentials().into_iter().map(|(n, _)| n).collect();
378            miette!(
379                help = if known.is_empty() {
380                    "no credentials are configured for this tool".to_string()
381                } else {
382                    format!("known: {}", known.join(", "))
383                },
384                "no credential named `{name}`"
385            )
386        },
387    )
388}