use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct PoiTrackerConfig {
#[serde(default)]
pub bugzilla: BugzillaConfig,
#[serde(default, rename = "dist-git")]
pub dist_git: DistGitConfig,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct DistGitConfig {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub api_token: String,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct BugzillaConfig {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub api_key: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub url: String,
}
impl BugzillaConfig {
pub const DEFAULT_URL: &'static str = "https://bugzilla.redhat.com";
}
fn config_file() -> sandogasa_config::ConfigFile {
sandogasa_config::ConfigFile::for_tool("poi-tracker")
}
fn load() -> Result<PoiTrackerConfig, Box<dyn std::error::Error>> {
config_file().load()
}
pub fn resolve_api_key(cli_key: Option<&str>) -> Result<String, String> {
if let Some(key) = cli_key
&& !key.is_empty()
{
return Ok(key.to_string());
}
let file = load().map(|c| c.bugzilla.api_key).unwrap_or_default();
sandogasa_config::secret::resolve(
crate::secrets::keyring(),
Some("BUGZILLA_API_KEY"),
&crate::secrets::bugzilla(&resolve_url()),
Some(&file),
)
.ok_or_else(|| {
"Bugzilla API key not found.\n\
Set it up with: poi-tracker config\n\
Or pass --api-key or set BUGZILLA_API_KEY."
.to_string()
})
}
pub fn resolve_distgit_token(cli_token: Option<&str>) -> Result<String, String> {
if let Some(token) = cli_token
&& !token.is_empty()
{
return Ok(token.to_string());
}
let file = load().map(|c| c.dist_git.api_token).unwrap_or_default();
sandogasa_config::secret::resolve(
crate::secrets::keyring(),
Some("PAGURE_API_TOKEN"),
&crate::secrets::distgit(),
Some(&file),
)
.ok_or_else(|| {
"dist-git API token not found.\n\
Generate one at https://src.fedoraproject.org/settings/token/new\n\
with the \"Modify an existing project\" ACL, then set it up\n\
with: poi-tracker config\n\
Or pass --api-token or set PAGURE_API_TOKEN."
.to_string()
})
}
pub fn resolve_url() -> String {
load()
.ok()
.filter(|c| !c.bugzilla.url.is_empty())
.map(|c| c.bugzilla.url)
.unwrap_or_else(|| BugzillaConfig::DEFAULT_URL.to_string())
}
pub fn resolve_email() -> Option<String> {
load()
.ok()
.map(|c| c.bugzilla.email)
.filter(|e| !e.is_empty())
}
pub async fn cmd_config(args: &sandogasa_cli::ConfigArgs) -> Result<(), String> {
use sandogasa_config::{Setting, ask};
let cf = config_file();
let mut config: PoiTrackerConfig = cf.load_user().unwrap_or_default();
println!("poi-tracker configuration\n");
println!("Config file: {}\n", cf.path().display());
if config.bugzilla.url.is_empty() {
config.bugzilla.url = BugzillaConfig::DEFAULT_URL.to_string();
}
println!("Bugzilla URL: {}", config.bugzilla.url);
let before = sandogasa_config::secret::fill(
crate::secrets::keyring(),
env!("CARGO_PKG_NAME"),
slots(&mut config),
args.all,
&sandogasa_cli::ask_to_reenable,
);
config.bugzilla.api_key = ask(
&Setting::new("Bugzilla", "API key").sensitive().hint(
"\nGenerate an API key at:\n \
https://bugzilla.redhat.com/userprefs.cgi?tab=apikey\n",
),
&config.bugzilla.api_key,
args.all,
)
.map_err(|e| format!("failed to read API key: {e}"))?;
config.bugzilla.email = ask(
&Setting::new("Bugzilla", "email (optional, for --claim)")
.optional()
.validated(&sandogasa_config::validate_email),
&config.bugzilla.email,
args.all,
)
.map_err(|e| format!("failed to read e-mail: {e}"))?;
config.dist_git.api_token = ask(
&Setting::new("dist-git", "API token (optional, empty to skip)")
.sensitive()
.optional()
.hint(
"\nOptional: a dist-git API token lets `adopt` take orphaned\n\
packages. Generate one at\n \
https://src.fedoraproject.org/settings/token/new\n\
with the \"Modify an existing project\" ACL.",
),
&config.dist_git.api_token,
args.all,
)
.map_err(|e| format!("failed to read token: {e}"))?;
let url = config.bugzilla.url.clone();
config.bugzilla.api_key = sandogasa_cli::credential::validated_key(
"Bugzilla",
"API key",
std::mem::take(&mut config.bugzilla.api_key),
move |key| {
let url = url.clone();
Box::pin(async move {
use sandogasa_cli::credential::KeyVerdict;
let bz = match sandogasa_bugzilla::BzClient::new(&url).with_api_key(key) {
Ok(bz) => bz,
Err(e) => return KeyVerdict::Unknown(format!("could not check: {e}")),
};
match bz.check_api_key().await {
Ok(sandogasa_bugzilla::KeyCheck::Accepted) => {
KeyVerdict::Accepted("accepted.".to_string())
}
Ok(sandogasa_bugzilla::KeyCheck::Refused(why)) => {
KeyVerdict::Refused(format!("refused: {why}"))
}
Ok(sandogasa_bugzilla::KeyCheck::Unclear(why)) => {
KeyVerdict::Unknown(format!("could not tell: {why}"))
}
Ok(_) => KeyVerdict::Unknown("could not tell.".to_string()),
Err(e) => KeyVerdict::Unknown(format!("could not check: {e}")),
}
})
},
)
.await
.key;
let removed = save_keeping_secrets(&cf, config, &before)
.map_err(|e| format!("failed to save config: {e}"))?;
if removed {
println!(
"\nNothing else to keep in {}; removed it.",
cf.path().display()
);
} else {
println!("\nConfig saved to {}", cf.path().display());
}
Ok(())
}
fn slots(config: &mut PoiTrackerConfig) -> Vec<sandogasa_config::secret::Slot<'_>> {
use sandogasa_config::secret::Slot;
vec![
Slot {
table: "bugzilla",
key: "api_key",
what: "Bugzilla API key",
entry: crate::secrets::bugzilla(&config.bugzilla.url),
value: &mut config.bugzilla.api_key,
required: true,
},
Slot {
table: "dist-git",
key: "api_token",
what: "Pagure token",
entry: crate::secrets::distgit(),
value: &mut config.dist_git.api_token,
required: false,
},
]
}
fn save_keeping_secrets(
cf: &sandogasa_config::ConfigFile,
mut config: PoiTrackerConfig,
before: &[sandogasa_config::secret::Before],
) -> Result<bool, Box<dyn std::error::Error>> {
let kept = sandogasa_config::secret::save_with(
cf,
crate::secrets::keyring(),
env!("CARGO_PKG_NAME"),
&mut config,
before,
slots,
)?;
Ok(!kept)
}
#[cfg(test)]
mod tests {
use super::*;
use sandogasa_config::test_support::EnvGuard;
const PLAIN_TEXT: &str = "[bugzilla]\n\
api_key = \"bz-from-file\"\n\
email = \"me@example.com\"\n\
url = \"https://bugzilla.keyring.test\"\n\
\n\
[dist-git]\n\
api_token = \"pagure-from-file\"\n";
fn isolated(dir: &tempfile::TempDir) -> EnvGuard {
EnvGuard::new(&[("XDG_CONFIG_HOME", &dir.path().to_string_lossy())])
.unset(&["BUGZILLA_API_KEY", "PAGURE_API_TOKEN"])
}
fn write_config(text: &str) {
let path = config_file().path().to_path_buf();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
}
fn clear_keyring() {
let keyring = crate::secrets::keyring();
keyring
.clear(&crate::secrets::bugzilla("https://bugzilla.keyring.test"))
.unwrap();
keyring.clear(&crate::secrets::distgit()).unwrap();
}
#[test]
#[serial_test::serial]
fn secrets_kept_in_the_keyring_are_the_ones_used() {
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
write_config(PLAIN_TEXT);
assert_eq!(resolve_api_key(None).unwrap(), "bz-from-file");
assert_eq!(resolve_distgit_token(None).unwrap(), "pagure-from-file");
let cf = config_file();
let removed = save_keeping_secrets(&cf, cf.load_user().unwrap(), &set_from_file()).unwrap();
assert!(!removed, "the email and URL are still in the file");
let text = std::fs::read_to_string(cf.path()).unwrap();
assert!(!text.contains("from-file"), "the file is left without them");
assert!(!text.contains("api_key") && !text.contains("api_token"));
assert!(text.contains("me@example.com"));
assert_eq!(resolve_api_key(None).unwrap(), "bz-from-file");
assert_eq!(resolve_distgit_token(None).unwrap(), "pagure-from-file");
let _env2 = EnvGuard::new(&[
("BUGZILLA_API_KEY", "bz-from-env"),
("PAGURE_API_TOKEN", "pagure-from-env"),
]);
assert_eq!(resolve_api_key(None).unwrap(), "bz-from-env");
assert_eq!(resolve_distgit_token(None).unwrap(), "pagure-from-env");
assert_eq!(resolve_api_key(Some("bz-cli")).unwrap(), "bz-cli");
assert_eq!(
resolve_distgit_token(Some("pagure-cli")).unwrap(),
"pagure-cli"
);
clear_keyring();
}
#[test]
#[serial_test::serial]
fn a_cleared_token_leaves_the_file() {
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
write_config(PLAIN_TEXT);
let cf = config_file();
let mut config: PoiTrackerConfig = cf.load_user().unwrap();
config.dist_git.api_token.clear();
save_keeping_secrets(&cf, config, &set_from_file()).unwrap();
let text = std::fs::read_to_string(cf.path()).unwrap();
assert!(!text.contains("pagure-from-file"), "{text}");
assert!(text.contains("api_token = \"-\""), "{text}");
assert!(resolve_distgit_token(None).is_err());
assert_eq!(resolve_api_key(None).unwrap(), "bz-from-file");
clear_keyring();
}
fn set_from_file() -> [sandogasa_config::secret::Before; 2] {
let set = sandogasa_config::secret::Before::Set { keyring: None };
[set.clone(), set]
}
fn both_off() -> PoiTrackerConfig {
let off = sandogasa_config::secret::OFF;
PoiTrackerConfig {
bugzilla: BugzillaConfig {
api_key: off.into(),
url: "https://bugzilla.keyring.test".into(),
..Default::default()
},
dist_git: DistGitConfig {
api_token: off.into(),
},
}
}
fn fill(
config: &mut PoiTrackerConfig,
review: bool,
ask: &dyn Fn(&str, bool) -> bool,
) -> Vec<sandogasa_config::secret::Before> {
sandogasa_config::secret::fill(
crate::secrets::keyring(),
"poi-tracker",
slots(config),
review,
ask,
)
}
fn recording(
asked: &std::cell::RefCell<Vec<(String, bool)>>,
answer: bool,
) -> impl Fn(&str, bool) -> bool + '_ {
move |q, default_yes| {
asked.borrow_mut().push((q.to_string(), default_yes));
answer
}
}
fn store_both(bugzilla: &str, pagure: &str) {
let keyring = crate::secrets::keyring();
keyring
.store(
&crate::secrets::bugzilla("https://bugzilla.keyring.test"),
bugzilla,
)
.unwrap();
keyring.store(&crate::secrets::distgit(), pagure).unwrap();
}
#[test]
#[serial_test::serial]
fn the_prompts_offer_the_keyrings_secret() {
use sandogasa_config::secret::{Before, never};
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
let mut config = both_off();
config.bugzilla.api_key = "bz-stale".into();
config.dist_git.api_token.clear();
crate::secrets::keyring()
.store(
&crate::secrets::bugzilla("https://bugzilla.keyring.test"),
"bz-fresh",
)
.unwrap();
let before = fill(&mut config, false, &never);
assert_eq!(
before,
[
Before::Set {
keyring: Some("bz-fresh".into())
},
Before::Absent
]
);
assert_eq!(config.bugzilla.api_key, "bz-fresh");
clear_keyring();
}
#[test]
#[serial_test::serial]
fn a_plain_run_asks_only_about_the_required_key() {
use sandogasa_config::secret::{Before, OFF};
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
let none = std::cell::RefCell::new(Vec::new());
let mut config = both_off();
assert_eq!(
fill(&mut config, false, &recording(&none, true)),
[Before::Off, Before::Off]
);
assert!(none.borrow().is_empty(), "no entry, nothing to ask");
assert_eq!(config.bugzilla.api_key, "");
assert_eq!(config.dist_git.api_token, OFF);
store_both("bz-kept", "pagure-kept");
let asked = std::cell::RefCell::new(Vec::new());
let mut config = both_off();
fill(&mut config, false, &recording(&asked, false));
assert_eq!(config.bugzilla.api_key, "");
assert_eq!(config.dist_git.api_token, OFF);
let mut config = both_off();
fill(&mut config, false, &recording(&asked, true));
assert_eq!(config.bugzilla.api_key, "bz-kept");
assert_eq!(config.dist_git.api_token, OFF);
let q = "Bugzilla API key: off for poi-tracker (the keyring has one). Use it again?";
assert_eq!(
asked.into_inner(),
[(q.to_string(), true), (q.to_string(), true)]
);
clear_keyring();
}
#[test]
#[serial_test::serial]
fn a_review_offers_the_optional_token_too() {
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
store_both("bz-kept", "pagure-kept");
let asked = std::cell::RefCell::new(Vec::new());
let mut config = both_off();
fill(&mut config, true, &recording(&asked, false));
assert_eq!(config.dist_git.api_token, "");
let mut config = both_off();
fill(&mut config, true, &|q, default_yes| {
asked.borrow_mut().push((q.to_string(), default_yes));
q.starts_with("Pagure")
});
assert_eq!(config.bugzilla.api_key, "");
assert_eq!(config.dist_git.api_token, "pagure-kept");
let bz = "Bugzilla API key: off for poi-tracker (the keyring has one). Use it again?";
let pagure = "Pagure token: off for poi-tracker (the keyring has one). Use it again?";
assert_eq!(
asked.into_inner(),
[
(bz.to_string(), true),
(pagure.to_string(), false),
(bz.to_string(), true),
(pagure.to_string(), false),
]
);
clear_keyring();
}
#[test]
#[serial_test::serial]
fn nothing_set_says_how_to_set_it_up() {
let dir = tempfile::tempdir().unwrap();
let _env = isolated(&dir);
assert!(
resolve_api_key(None)
.unwrap_err()
.contains("poi-tracker config")
);
assert!(
resolve_distgit_token(None)
.unwrap_err()
.contains("PAGURE_API_TOKEN")
);
assert_eq!(resolve_url(), BugzillaConfig::DEFAULT_URL);
assert_eq!(resolve_email(), None);
}
}