use url::Url;
use crate::config::Config;
use crate::error::{BzrError, Result};
use crate::types::{QueryKind, SavedQuery, FIELD_MAPPINGS};
const CREDENTIAL_PARAMS: &[&str] = &["bugzilla_api_key", "token", "api_key"];
const IGNORED_PARAMS: &[&str] = &["columnlist", "list_id", "query_format"];
#[derive(Debug)]
pub struct ParsedUrl {
pub query: SavedQuery,
pub suggested_name: Option<String>,
}
fn sanitize_url(url: &Url) -> String {
let mut sanitized = url.clone();
let pairs: Vec<(String, String)> = sanitized
.query_pairs()
.filter(|(k, _)| !CREDENTIAL_PARAMS.contains(&k.to_ascii_lowercase().as_str()))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if pairs.is_empty() {
sanitized.set_query(None);
} else {
sanitized.query_pairs_mut().clear().extend_pairs(pairs);
}
sanitized.to_string()
}
pub fn parse_bugzilla_url(url_str: &str, config: &Config) -> Result<ParsedUrl> {
let url =
Url::parse(url_str).map_err(|e| BzrError::InputValidation(format!("invalid URL: {e}")))?;
if !url.path().contains("buglist.cgi") {
return Err(BzrError::InputValidation(
"URL must be a Bugzilla buglist.cgi URL".into(),
));
}
let url_host = url
.host_str()
.ok_or_else(|| BzrError::InputValidation("URL has no hostname".into()))?;
let server = find_server_by_hostname(config, url_host);
if server.is_none() && config.default_server.is_none() {
return Err(BzrError::config(format!(
"URL hostname '{url_host}' does not match any configured server \
and no default server is set. Run `bzr config set-server` first."
)));
}
if server.is_none() {
tracing::warn!(
"URL hostname '{url_host}' does not match any configured server; \
using default server"
);
}
let mut query = SavedQuery {
kind: QueryKind::Url,
source_url: Some(sanitize_url(&url)),
server: server.map(String::from),
..SavedQuery::default()
};
let mut known_name: Option<String> = None;
let mut query_based_on: Option<String> = None;
for (key, value) in url.query_pairs() {
let key = key.as_ref();
let value = value.as_ref();
if IGNORED_PARAMS.contains(&key) {
continue;
}
if key == "known_name" {
let trimmed = value.trim();
if !trimmed.is_empty() {
known_name = Some(trimmed.to_string());
}
continue;
}
if key == "query_based_on" {
let trimmed = value.trim();
if !trimmed.is_empty() {
query_based_on = Some(trimmed.to_string());
}
continue;
}
if key == "limit" {
if let Ok(n) = value.parse::<u32>() {
query.limit = Some(n);
}
continue;
}
if let Some(mapping) = FIELD_MAPPINGS.iter().find(|m| m.url_param == key) {
let Some(target) = query.get_field_mut(mapping.struct_field) else {
unreachable!(
"FIELD_MAPPINGS struct_field '{}' missing from get_field_mut",
mapping.struct_field
);
};
target.push(value.to_string());
continue;
}
if CREDENTIAL_PARAMS.contains(&key.to_ascii_lowercase().as_str()) {
tracing::warn!("stripping credential parameter '{key}' from URL");
continue;
}
query.raw_params.push((key.to_string(), value.to_string()));
}
Ok(ParsedUrl {
query,
suggested_name: known_name.or(query_based_on),
})
}
fn find_server_by_hostname<'a>(config: &'a Config, hostname: &str) -> Option<&'a str> {
for (name, srv) in &config.servers {
if let Ok(srv_url) = Url::parse(&srv.url) {
if srv_url.host_str() == Some(hostname) {
return Some(name.as_str());
}
}
}
None
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::config::{Config, ServerConfig};
fn make_server_config(server_url: &str) -> ServerConfig {
ServerConfig {
url: server_url.into(),
api_key: None,
api_key_env: None,
api_key_keyring: None,
email: None,
auth_method: None,
api_mode: None,
server_version: None,
tls_insecure: false,
}
}
fn make_config(server_url: &str) -> Config {
let mut servers = HashMap::new();
servers.insert("test".to_string(), make_server_config(server_url));
Config {
default_server: Some("test".to_string()),
servers,
templates: HashMap::new(),
queries: HashMap::new(),
}
}
fn parse_test_url(query: &str) -> ParsedUrl {
let config = make_config("https://bugzilla.example.com");
let url = format!("https://bugzilla.example.com/buglist.cgi?{query}");
parse_bugzilla_url(&url, &config).unwrap()
}
fn raw_keys(parsed: &ParsedUrl) -> Vec<&str> {
parsed
.query
.raw_params
.iter()
.map(|(k, _)| k.as_str())
.collect()
}
fn raw_value<'a>(parsed: &'a ParsedUrl, key: &str) -> &'a str {
parsed
.query
.raw_params
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
.unwrap()
}
#[test]
fn parse_simple_url_with_recognized_params() {
let parsed = parse_test_url("product=Firefox&product=Thunderbird&bug_status=NEW&limit=50");
assert_eq!(parsed.query.product, vec!["Firefox", "Thunderbird"]);
assert_eq!(parsed.query.status, vec!["NEW"]);
assert_eq!(parsed.query.limit, Some(50));
assert!(parsed.query.raw_params.is_empty());
assert_eq!(parsed.query.server.as_deref(), Some("test"));
}
#[test]
fn parse_complex_boolean_chart_url() {
let parsed = parse_test_url(
"known_name=My+Query\
&query_format=advanced\
&list_id=12345\
&columnlist=bug_id%2Csummary\
&chfield=%5BBug+creation%5D\
&chfieldfrom=-7d\
&classification=Client+Software\
&f1=component\
&o1=equals\
&v1=PDF+Viewer",
);
let keys = raw_keys(&parsed);
assert!(!keys.contains(&"query_format"));
assert!(!keys.contains(&"list_id"));
assert!(!keys.contains(&"columnlist"));
assert!(!keys.contains(&"known_name"));
assert!(keys.contains(&"f1"));
assert!(keys.contains(&"o1"));
assert!(keys.contains(&"v1"));
assert!(keys.contains(&"chfield"));
assert!(keys.contains(&"chfieldfrom"));
assert!(keys.contains(&"classification"));
assert_eq!(raw_value(&parsed, "v1"), "PDF Viewer");
assert_eq!(raw_value(&parsed, "chfield"), "[Bug creation]");
}
#[test]
fn parse_url_without_buglist_cgi_errors() {
let config = make_config("https://bugzilla.example.com");
let err = parse_bugzilla_url(
"https://bugzilla.example.com/show_bug.cgi?id=12345",
&config,
)
.unwrap_err();
assert!(
err.to_string().contains("buglist.cgi"),
"error should mention buglist.cgi: {err}"
);
}
#[test]
fn parse_malformed_url_errors() {
let config = make_config("https://bugzilla.example.com");
let err = parse_bugzilla_url("not a url", &config).unwrap_err();
assert!(
err.to_string().contains("invalid URL"),
"error should mention invalid URL: {err}"
);
}
#[test]
fn parse_url_hostname_matches_configured_server() {
let parsed = parse_test_url("product=Firefox&bug_status=NEW");
assert_eq!(parsed.query.server.as_deref(), Some("test"));
}
#[test]
fn parse_url_hostname_no_match_uses_default() {
let config = make_config("https://other.example.com");
let parsed = parse_bugzilla_url(
"https://bugzilla.example.com/buglist.cgi?product=Firefox",
&config,
)
.unwrap();
assert!(parsed.query.server.is_none());
}
#[test]
fn parse_url_hostname_no_match_no_default_errors() {
let config = Config {
default_server: None,
servers: HashMap::new(),
templates: HashMap::new(),
queries: HashMap::new(),
};
let err = parse_bugzilla_url(
"https://bugzilla.example.com/buglist.cgi?product=Firefox",
&config,
)
.unwrap_err();
assert!(
err.to_string().contains("does not match"),
"error should mention does not match: {err}"
);
}
#[test]
fn parse_url_repeated_product_params_accumulate() {
let parsed = parse_test_url("product=Firefox&product=Thunderbird&product=SeaMonkey");
assert_eq!(
parsed.query.product,
vec!["Firefox", "Thunderbird", "SeaMonkey"]
);
}
#[test]
fn parse_url_decodes_percent_encoded_values() {
let parsed = parse_test_url("product=PPC64%20Development&assigned_to=user%40example.com");
assert_eq!(parsed.query.product, vec!["PPC64 Development"]);
assert_eq!(parsed.query.assignee, vec!["user@example.com"]);
}
#[test]
fn parse_url_all_recognized_fields() {
let parsed = parse_test_url(
"product=Firefox\
&component=General\
&bug_status=NEW\
&assigned_to=dev@example.com\
&reporter=reporter@example.com\
&priority=P1\
&bug_severity=major\
&limit=100",
);
assert_eq!(parsed.query.product, vec!["Firefox"]);
assert_eq!(parsed.query.component, vec!["General"]);
assert_eq!(parsed.query.status, vec!["NEW"]);
assert_eq!(parsed.query.assignee, vec!["dev@example.com"]);
assert_eq!(parsed.query.creator, vec!["reporter@example.com"]);
assert_eq!(parsed.query.priority, vec!["P1"]);
assert_eq!(parsed.query.severity, vec!["major"]);
assert_eq!(parsed.query.limit, Some(100));
assert!(parsed.query.raw_params.is_empty());
}
#[test]
fn parse_url_only_raw_params() {
let parsed = parse_test_url("f1=component&o1=equals&v1=PDF+Viewer");
assert!(parsed.query.product.is_empty());
assert_eq!(parsed.query.raw_params.len(), 3);
assert!(parsed.query.has_filters());
}
#[test]
fn find_server_by_hostname_matches() {
let config = make_config("https://bugzilla.example.com");
let result = find_server_by_hostname(&config, "bugzilla.example.com");
assert_eq!(result, Some("test"));
}
#[test]
fn find_server_by_hostname_no_match() {
let config = make_config("https://bugzilla.example.com");
let result = find_server_by_hostname(&config, "other.example.com");
assert!(result.is_none());
}
#[test]
fn parse_url_strips_api_key_from_raw_params() {
let parsed = parse_test_url(
"product=Firefox&Bugzilla_api_key=secret123&f1=component&o1=equals&v1=General",
);
let keys = raw_keys(&parsed);
assert!(!keys.contains(&"Bugzilla_api_key"));
assert!(!keys.contains(&"bugzilla_api_key"));
assert!(keys.contains(&"f1"));
assert!(keys.contains(&"o1"));
assert!(keys.contains(&"v1"));
assert_eq!(parsed.query.product, vec!["Firefox"]);
}
#[test]
fn parse_url_strips_credentials_from_source_url() {
let parsed = parse_test_url("product=Firefox&Bugzilla_api_key=secret123&token=abc");
let source = parsed.query.source_url.as_deref().unwrap();
assert!(
!source.contains("secret123"),
"API key leaked into source_url: {source}"
);
assert!(
!source.contains("abc"),
"token leaked into source_url: {source}"
);
assert!(
source.contains("product=Firefox"),
"non-credential params should remain: {source}"
);
}
#[test]
fn parses_known_name_into_suggested_name() {
let parsed = parse_test_url("product=Firefox&known_name=my%20saved%20search");
assert_eq!(parsed.suggested_name, Some("my saved search".into()));
}
#[test]
fn prefers_known_name_over_query_based_on() {
let parsed = parse_test_url("product=Firefox&known_name=preferred&query_based_on=ancestor");
assert_eq!(parsed.suggested_name, Some("preferred".into()));
}
#[test]
fn falls_back_to_query_based_on() {
let parsed = parse_test_url("product=Firefox&query_based_on=ancestor%20query");
assert_eq!(parsed.suggested_name, Some("ancestor query".into()));
}
#[test]
fn no_suggested_name_when_absent() {
let parsed = parse_test_url("product=Firefox");
assert!(parsed.suggested_name.is_none());
}
#[test]
fn empty_known_name_ignored() {
let parsed = parse_test_url("product=Firefox&known_name=");
assert!(parsed.suggested_name.is_none());
}
#[test]
fn parse_url_strips_credentials_case_insensitive() {
let parsed =
parse_test_url("product=Firefox&BUGZILLA_API_KEY=secret&Token=abc&api_key=def");
let source = parsed.query.source_url.as_deref().unwrap();
assert!(!source.contains("secret"));
assert!(!source.contains("abc"));
assert!(!source.contains("def"));
let keys = raw_keys(&parsed);
assert!(
keys.is_empty()
|| !keys
.iter()
.any(|k| k.to_ascii_lowercase().contains("key")
|| k.eq_ignore_ascii_case("token"))
);
}
}