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"];
enum ParamKind {
Ignored,
KnownName,
QueryBasedOn,
Limit,
Mapped(&'static crate::types::FieldMapping),
Credential,
Raw,
}
fn classify_param(key: &str) -> ParamKind {
if IGNORED_PARAMS.contains(&key) {
return ParamKind::Ignored;
}
match key {
"known_name" => return ParamKind::KnownName,
"query_based_on" => return ParamKind::QueryBasedOn,
"limit" => return ParamKind::Limit,
_ => {}
}
if let Some(mapping) = FIELD_MAPPINGS.iter().find(|m| m.url_param == key) {
return ParamKind::Mapped(mapping);
}
if CREDENTIAL_PARAMS.contains(&key.to_ascii_lowercase().as_str()) {
return ParamKind::Credential;
}
ParamKind::Raw
}
#[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()
}
fn strip_shell_backslashes(url: &str) -> String {
if !url.contains('\\') {
return url.to_string();
}
tracing::warn!(
"URL contains backslash-escaped characters (e.g. \\? \\& \\=); \
stripping shell escapes — quote the URL to avoid this"
);
let mut out = String::with_capacity(url.len());
let mut chars = url.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && matches!(chars.peek(), Some('?' | '&' | '=' | '%')) {
continue;
}
out.push(ch);
}
out
}
pub fn parse_bugzilla_url(url_str: &str, config: &Config) -> Result<ParsedUrl> {
let cleaned = strip_shell_backslashes(url_str);
let url =
Url::parse(&cleaned).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();
match classify_param(key) {
ParamKind::Ignored => {}
ParamKind::KnownName => {
let trimmed = value.trim();
if !trimmed.is_empty() {
known_name = Some(trimmed.to_string());
}
}
ParamKind::QueryBasedOn => {
let trimmed = value.trim();
if !trimmed.is_empty() {
query_based_on = Some(trimmed.to_string());
}
}
ParamKind::Limit => {
if let Ok(n) = value.parse::<u32>() {
query.limit = Some(n);
}
}
ParamKind::Mapped(mapping) => {
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());
}
ParamKind::Credential => {
tracing::warn!("stripping credential parameter '{key}' from URL");
}
ParamKind::Raw => {
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,
tls_ca_cert: None,
tls_pin_sha256: None,
tls_pin_issuer: None,
tls_pin_issuer_der: None,
}
}
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(),
}
}
#[test]
fn classify_param_kinds() {
assert!(matches!(classify_param("columnlist"), ParamKind::Ignored));
assert!(matches!(classify_param("known_name"), ParamKind::KnownName));
assert!(matches!(
classify_param("query_based_on"),
ParamKind::QueryBasedOn
));
assert!(matches!(classify_param("limit"), ParamKind::Limit));
assert!(matches!(classify_param("product"), ParamKind::Mapped(_)));
assert!(matches!(
classify_param("Bugzilla_api_key"),
ParamKind::Credential
));
assert!(matches!(classify_param("token"), ParamKind::Credential));
assert!(matches!(
classify_param("nonexistent_field"),
ParamKind::Raw
));
}
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"))
);
}
#[test]
fn strip_shell_backslashes_cleans_escaped_url() {
let escaped =
r"https://bugzilla.example.com/buglist.cgi\?product\=Firefox\&bug_status\=NEW";
let cleaned = strip_shell_backslashes(escaped);
assert_eq!(
cleaned,
"https://bugzilla.example.com/buglist.cgi?product=Firefox&bug_status=NEW"
);
}
#[test]
fn strip_shell_backslashes_preserves_clean_url() {
let clean = "https://bugzilla.example.com/buglist.cgi?product=Firefox";
let result = strip_shell_backslashes(clean);
assert_eq!(result, clean);
}
#[test]
fn strip_shell_backslashes_preserves_percent_encoding() {
let escaped = r"https://bugzilla.example.com/buglist.cgi\?product\=PPC64\%20Development";
let cleaned = strip_shell_backslashes(escaped);
assert_eq!(
cleaned,
"https://bugzilla.example.com/buglist.cgi?product=PPC64%20Development"
);
}
#[test]
fn strip_shell_backslashes_ignores_non_special() {
let input = r"https://bugzilla.example.com/buglist.cgi?summary=foo\bar";
let result = strip_shell_backslashes(input);
assert_eq!(result, input);
}
#[test]
fn strip_shell_backslashes_trailing_backslash() {
let input = r"https://bugzilla.example.com/buglist.cgi?product=Firefox\";
let result = strip_shell_backslashes(input);
assert_eq!(result, input);
}
#[test]
fn parse_url_with_shell_backslashes_succeeds() {
let config = make_config("https://bugzilla.example.com");
let escaped =
r"https://bugzilla.example.com/buglist.cgi\?product\=Firefox\&bug_status\=NEW";
let parsed = parse_bugzilla_url(escaped, &config).unwrap();
assert_eq!(parsed.query.product, vec!["Firefox"]);
assert_eq!(parsed.query.status, vec!["NEW"]);
}
#[test]
fn parse_url_with_shell_backslashes_boolean_chart() {
let config = make_config("https://bugzilla.example.com");
let escaped = r"https://bugzilla.example.com/buglist.cgi\?f1\=qa_contact\&o1\=changedfrom\&v1\=user\%40example.com\&classification\=Community";
let parsed = parse_bugzilla_url(escaped, &config).unwrap();
let keys = raw_keys(&parsed);
assert!(keys.contains(&"f1"), "boolean chart f1 missing: {keys:?}");
assert!(keys.contains(&"o1"), "boolean chart o1 missing: {keys:?}");
assert_eq!(raw_value(&parsed, "v1"), "user@example.com");
assert_eq!(raw_value(&parsed, "classification"), "Community");
}
}