1#![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
37pub 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 feature: Some(Feature::Credentials),
47 ..CommandSpec::DEFAULT
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 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#[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,
114 Add {
116 name: String,
119 },
120 Remove {
122 name: String,
124 },
125 Test {
127 name: String,
129 },
130 Doctor,
132}
133
134#[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 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
181async fn run_add(app: &App, name: &str) -> miette::Result<()> {
186 let cred = lookup_credential(app, name)?;
187
188 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 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 let _ = secret; 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
252fn 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
262async 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 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#[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
316async 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 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 _ => "unknown",
367 }
368}
369
370fn 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}