use std::io::{BufRead, IsTerminal, Read};
use anyhow::{Context, bail};
use ownserp_core::{Overrides, SecretString, Settings, keychain_delete, keychain_set};
use crate::cli::{ConfigAction, ConfigArgs};
use crate::output::emit;
pub(crate) fn run(args: ConfigArgs) -> anyhow::Result<()> {
match args.action {
ConfigAction::SetApiKey => set_key(),
ConfigAction::Show => show(),
ConfigAction::DeleteApiKey => {
keychain_delete().context("removing the key")?;
emit("stored key removed; the environment variable is untouched")?;
Ok(())
}
}
}
fn set_key() -> anyhow::Result<()> {
if std::io::stdin().is_terminal() {
bail!(
"stdin is a terminal and a typed key would be echoed; pipe the key instead, for example: printf '%s\\n' \"$SERPER_API_KEY\" | ownserp config set-api-key"
);
}
eprintln!("reading the API key from stdin");
let mut line = String::new();
std::io::stdin()
.lock()
.take(4096)
.read_line(&mut line)
.context("reading stdin")?;
let trimmed = clean_key(&line)?;
let secret = SecretString::from(trimmed);
keychain_set(&secret).context("storing the key in the OS keychain")?;
emit("stored the key in the OS keychain")?;
Ok(())
}
fn clean_key(raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("no key on stdin");
}
if trimmed.chars().count() > 512 {
bail!("the key is longer than 512 characters");
}
if trimmed.chars().any(char::is_control) {
bail!("the key contains a control character");
}
Ok(trimmed.to_owned())
}
fn show() -> anyhow::Result<()> {
let settings = Settings::load(Overrides::default()).context("resolving settings")?;
for line in summary(&settings) {
emit(&line)?;
}
Ok(())
}
fn summary(settings: &Settings) -> Vec<String> {
vec![
format!("api key source: {}", settings.api_key_source().label()),
format!("api key: {}", settings.masked_key()),
format!("pack tier: {:?}", settings.pack()),
format!("ledger directory: {}", settings.ledger_dir().display()),
format!(
"profile: {}",
settings
.profile_path()
.map_or_else(|| "none".to_owned(), |path| path.display().to_string())
),
]
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
fn settings_with_key() -> Settings {
let map = BTreeMap::from([("SERPER_API_KEY".to_owned(), "abcdefgh-test-key".to_owned())]);
let profile = std::env::temp_dir().join("ownserp-config-summary.toml");
std::fs::write(&profile, "").expect("profile");
Settings::load_with(
Overrides {
profile: Some(profile),
ledger_dir: Some(std::env::temp_dir().join("ownserp-config-summary-ledger")),
cache_ttl_secs: Some(0),
..Overrides::default()
},
|name| map.get(name).cloned(),
)
.expect("settings")
}
#[test]
fn a_key_line_is_trimmed_and_bounded() {
assert_eq!(clean_key(" abc123 \n").expect("key"), "abc123");
assert!(clean_key(" ").is_err());
assert!(clean_key(&"a".repeat(513)).is_err());
assert!(clean_key("abc\u{7}").is_err());
}
#[test]
fn the_summary_masks_the_key_and_names_every_field() {
let lines = summary(&settings_with_key());
assert_eq!(lines.len(), 5);
let joined = lines.join("\n");
assert!(joined.contains("api key source: environment"));
assert!(joined.contains("api key: ****-key"));
assert!(!joined.contains("abcdefgh-test-key"));
assert!(joined.contains("pack tier: Starter"));
assert!(joined.contains("ledger directory:"));
}
}