use anyhow::{Result, bail};
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
use url::Url;
use crate::filter::FeatureFilter;
use crate::osm::OsmData;
const DEFAULT_OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
const OVERPASS_TIMEOUT_SECS: u64 = 60;
const OVERPASS_USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (",
env!("CARGO_PKG_REPOSITORY"),
")"
);
const ALLOWED_OVERPASS_HOSTS: &[&str] = &[
"overpass-api.de",
"overpass.kumi.systems",
"overpass.openstreetmap.ru",
"maps.mail.ru",
"overpass.osm.ch",
];
pub fn validate_overpass_url(url: &str) -> Result<()> {
let parsed =
Url::parse(url).map_err(|err| anyhow::anyhow!("Invalid Overpass URL '{url}': {err}"))?;
if parsed.scheme() != "https" {
bail!("Overpass URL must use HTTPS (got: '{url}')");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("Overpass URL must not include userinfo");
}
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("Overpass URL has no host"))?;
if !ALLOWED_OVERPASS_HOSTS.contains(&host) {
bail!(
"Overpass host '{}' is not in the approved list. \
Allowed hosts: {}",
host,
ALLOWED_OVERPASS_HOSTS.join(", ")
);
}
Ok(())
}
pub fn default_overpass_url() -> &'static str {
use std::sync::OnceLock;
static RESOLVED: OnceLock<String> = OnceLock::new();
RESOLVED
.get_or_init(|| {
std::env::var("OVERPASS_URL").unwrap_or_else(|_| DEFAULT_OVERPASS_URL.to_string())
})
.as_str()
}
pub fn build_overpass_query(bbox: (f64, f64, f64, f64), filter: &FeatureFilter) -> Result<String> {
let (south, west, north, east) = bbox;
if south >= north {
bail!("invalid bbox: south ({south}) must be less than north ({north})");
}
if west >= east {
bail!("invalid bbox: west ({west}) must be less than east ({east})");
}
let b = format!("{south},{west},{north},{east}");
let mut parts: Vec<String> = Vec::new();
if filter.roads {
parts.push(format!(r#"way["highway"]({b});"#));
}
if filter.buildings {
parts.push(format!(r#"way["building"]({b});"#));
parts.push(format!(r#"node["addr:housenumber"]({b});"#));
}
if filter.water {
parts.push(format!(r#"way["waterway"]({b});"#));
parts.push(format!(r#"way["natural"="water"]({b});"#));
}
if filter.landuse {
parts.push(format!(r#"way["landuse"]({b});"#));
parts.push(format!(r#"way["natural"]({b});"#));
}
if filter.railways {
parts.push(format!(r#"way["railway"="rail"]({b});"#));
}
for element in ["node", "way"] {
parts.push(format!(r#"{element}["amenity"]({b});"#));
parts.push(format!(r#"{element}["shop"]({b});"#));
parts.push(format!(r#"{element}["tourism"]({b});"#));
parts.push(format!(r#"{element}["leisure"]({b});"#));
parts.push(format!(r#"{element}["historic"]({b});"#));
parts.push(format!(
r#"{element}["man_made"~"^(tower|water_tower|chimney)$"]({b});"#
));
}
parts.push(format!(r#"node["natural"="tree"]({b});"#));
parts.push(format!(r#"node["natural"~"^(peak|rock|spring)$"]({b});"#));
if parts.is_empty() {
bail!("all feature types are disabled — nothing to query");
}
Ok(format!(
"[out:xml][timeout:{OVERPASS_TIMEOUT_SECS}];\n({});\nout body;>;out skel qt;",
parts.join("")
))
}
pub fn fetch_osm_xml(
bbox: (f64, f64, f64, f64),
filter: &FeatureFilter,
overpass_url: &str,
) -> Result<String> {
validate_overpass_url(overpass_url)?;
let query = build_overpass_query(bbox, filter)?;
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(OVERPASS_TIMEOUT_SECS))
.build()?;
let request = build_overpass_request(&client, overpass_url, &query)?;
let res = client.execute(request)?;
if res.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
bail!("Overpass is busy — try again in a few minutes");
}
if !res.status().is_success() {
let status = res.status();
let body = res.text().unwrap_or_default();
bail!("Overpass API error ({status}): {body}");
}
Ok(res.text()?)
}
fn build_overpass_request(
client: &reqwest::blocking::Client,
overpass_url: &str,
query: &str,
) -> Result<reqwest::blocking::Request> {
Ok(client
.post(overpass_url)
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(USER_AGENT, OVERPASS_USER_AGENT)
.body(format!("data={}", urlencoding::encode(query)))
.build()?)
}
pub fn fetch_osm_data(
bbox: (f64, f64, f64, f64),
filter: &FeatureFilter,
use_cache: bool,
overpass_url: &str,
) -> Result<OsmData> {
let key = crate::osm_cache::cache_key_for_url(bbox, filter, overpass_url);
if use_cache {
if let Some(xml) = crate::osm_cache::read_for_url(&key, overpass_url) {
log::info!("Cache hit for key {}", &key[..8]);
return crate::osm::parse_osm_xml_str(&xml);
}
if let Some(xml) = crate::osm_cache::find_containing_for_url(bbox, filter, overpass_url) {
log::info!("Cache containment hit — reusing larger cached area");
return crate::osm::parse_osm_xml_str(&xml);
}
log::info!("Cache miss — fetching from Overpass (bbox {bbox:?})");
} else {
log::info!("Force-fetching from Overpass (bbox {bbox:?})");
}
let xml = fetch_osm_xml(bbox, filter, overpass_url)?;
if let Err(e) = crate::osm_cache::write_for_url(&key, bbox, filter, &xml, overpass_url) {
log::warn!("Cache write failed: {e}");
}
crate::osm::parse_osm_xml_str(&xml)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::FeatureFilter;
#[test]
fn overpass_request_includes_user_agent() {
let client = reqwest::blocking::Client::builder().build().unwrap();
let request =
build_overpass_request(&client, default_overpass_url(), "node(0,0,1,1);").unwrap();
let user_agent = request
.headers()
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.unwrap();
assert!(user_agent.contains("par-osm-rust/"));
assert_eq!(
request.headers().get(CONTENT_TYPE).unwrap(),
"application/x-www-form-urlencoded"
);
}
#[test]
fn query_includes_all_types_by_default() {
let filter = FeatureFilter::default();
let q = build_overpass_query((51.5, -0.13, 51.52, -0.10), &filter).unwrap();
assert!(q.contains(r#"way["highway"]"#), "missing highway");
assert!(q.contains(r#"way["building"]"#), "missing building");
assert!(q.contains(r#"way["waterway"]"#), "missing waterway");
assert!(
q.contains(r#"way["natural"="water"]"#),
"missing natural water"
);
assert!(q.contains(r#"way["landuse"]"#), "missing landuse");
assert!(q.contains(r#"way["railway"="rail"]"#), "missing railway");
assert!(
q.contains(r#"node["natural"="tree"]"#),
"missing tree nodes"
);
assert!(
q.contains(r#"node["natural"~"^(peak|rock|spring)$"]"#),
"missing nature nodes"
);
assert!(
q.contains(r#"node["man_made"~"^(tower|water_tower|chimney)$"]"#),
"missing man-made landmark nodes"
);
assert!(q.contains(r#"way["amenity"]"#), "missing POI ways");
assert!(q.contains(r#"way["shop"]"#), "missing shop ways");
}
#[test]
fn query_excludes_disabled_roads() {
let filter = FeatureFilter {
roads: false,
..FeatureFilter::default()
};
let q = build_overpass_query((51.5, -0.13, 51.52, -0.10), &filter).unwrap();
assert!(!q.contains(r#"way["highway"]"#));
assert!(q.contains(r#"way["building"]"#)); }
#[test]
fn query_excludes_disabled_water() {
let filter = FeatureFilter {
water: false,
..FeatureFilter::default()
};
let q = build_overpass_query((51.5, -0.13, 51.52, -0.10), &filter).unwrap();
assert!(!q.contains(r#"way["waterway"]"#));
assert!(!q.contains(r#"way["natural"="water"]"#));
}
#[test]
fn query_contains_bbox_coords() {
let filter = FeatureFilter::default();
let q = build_overpass_query((51.5, -0.13, 51.52, -0.10), &filter).unwrap();
assert!(q.contains("51.5"), "missing south");
assert!(q.contains("-0.13"), "missing west");
assert!(q.contains("51.52"), "missing north");
assert!(q.contains("-0.1"), "missing east");
}
#[test]
fn invalid_bbox_south_gt_north() {
let filter = FeatureFilter::default();
let result = build_overpass_query((51.52, -0.13, 51.5, -0.10), &filter);
assert!(result.is_err(), "should fail when south >= north");
}
#[test]
fn invalid_bbox_west_gt_east() {
let filter = FeatureFilter::default();
let result = build_overpass_query((51.5, -0.10, 51.52, -0.13), &filter);
assert!(result.is_err(), "should fail when west >= east");
}
#[test]
fn all_disabled_still_queries_poi_nodes() {
let filter = FeatureFilter {
roads: false,
buildings: false,
water: false,
landuse: false,
railways: false,
};
let q = build_overpass_query((51.5, -0.13, 51.52, -0.10), &filter).unwrap();
assert!(
q.contains(r#"node["amenity"]"#),
"POI node queries should always be present"
);
assert!(
q.contains(r#"way["amenity"]"#),
"POI way queries should always be present"
);
assert!(
q.contains(r#"node["natural"="tree"]"#),
"tree node queries should always be present"
);
assert!(!q.contains(r#"way["highway"]"#), "roads should be absent");
assert!(
!q.contains(r#"way["building"]"#),
"buildings should be absent"
);
}
#[test]
fn valid_default_overpass_url_is_accepted() {
assert!(validate_overpass_url("https://overpass-api.de/api/interpreter").is_ok());
}
#[test]
fn valid_mirror_url_is_accepted() {
assert!(validate_overpass_url("https://overpass.kumi.systems/api/interpreter").is_ok());
}
#[test]
fn http_scheme_is_rejected() {
let err = validate_overpass_url("http://overpass-api.de/api/interpreter");
assert!(err.is_err(), "HTTP should be rejected");
let msg = err.unwrap_err().to_string();
assert!(msg.contains("HTTPS"), "error should mention HTTPS: {msg}");
}
#[test]
fn unknown_host_is_rejected() {
let err = validate_overpass_url("https://evil.example.com/api/interpreter");
assert!(err.is_err(), "unknown host should be rejected");
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("approved list"),
"error should mention approved list: {msg}"
);
}
#[test]
fn ssrf_metadata_url_is_rejected() {
assert!(
validate_overpass_url("https://169.254.169.254/latest/meta-data/").is_err(),
"AWS metadata URL must be rejected"
);
}
#[test]
fn internal_ip_http_is_rejected() {
assert!(
validate_overpass_url("http://192.168.1.1/overpass").is_err(),
"RFC-1918 HTTP URL must be rejected"
);
}
#[test]
fn url_with_port_on_approved_host_is_accepted() {
assert!(
validate_overpass_url("https://overpass-api.de:443/api/interpreter").is_ok(),
"explicit port on approved host should be allowed"
);
}
#[test]
fn url_with_allowed_host_in_userinfo_and_evil_host_is_rejected() {
assert!(
validate_overpass_url("https://overpass-api.de:443@evil.example.com/api/interpreter")
.is_err(),
"allowed host embedded in userinfo must be rejected"
);
}
#[test]
fn url_with_userinfo_on_allowed_host_is_rejected() {
assert!(
validate_overpass_url("https://user:pass@overpass-api.de/api/interpreter").is_err(),
"userinfo must be rejected even when host is approved"
);
}
}