//! GPS track loading (GPX). //! //! Minimal streaming parser built directly on quick-xml: we only need the //! `trkpt` positions, and real-world files (Garmin etc.) are full of vendor //! `` blocks that stricter parsers choke on. Waypoints and //! routes are ignored; only track points are read. //! //! Security posture for untrusted files: //! - quick-xml is used with `default-features = false` (no serde, no encoding //! detection); it never fetches external DTDs/entities and expands only the //! five predefined XML entities, so XXE and entity-expansion ("billion //! laughs") attacks are structurally impossible; unknown entity references //! make the attribute fail to parse and the point is skipped //! - input size is capped (MAX_GPX_BYTES) before anything is parsed //! - the parser only reads lat/lon attributes and text; all other //! elements (including vendor extensions) are ignored without being //! interpreted use crate::hill::{Hill, FLAG_TRACK_POINT}; use quick_xml::events::Event; /// Reject absurdly large track files (a 64 MiB GPX is already ~500k points). pub const MAX_GPX_BYTES: u64 = 64 * 1024 * 1024; /// Load track points from a GPX file (all tracks/segments, in file order). /// /// Consecutive duplicate positions are dropped; the result is decimated with /// a uniform stride to at most `max_points` (GPX logs easily reach 10k /// points, which would bloat the panorama's hill list). pub fn load_gpx(path: &str, max_points: usize) -> Result, String> { let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; if size.len() > MAX_GPX_BYTES { return Err(format!("{path}: file too large ({} bytes)", size.len())); } let data = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; let mut reader = quick_xml::Reader::from_str(&data); reader.config_mut().trim_text(true); let mut pts: Vec<(f64, f64, f64)> = Vec::new(); // (lat, lon, ele) degrees let mut depth: usize = 0; let mut trkpt_depth: Option = None; let mut in_ele = false; let (mut lat, mut lon, mut ele) = (f64::NAN, f64::NAN, 0.0); fn push(pts: &mut Vec<(f64, f64, f64)>, lat: f64, lon: f64, ele: f64) { if lat.is_nan() || lon.is_nan() { return; } if let Some(&(lata, lona, _)) = pts.last() { if lata == lat && lona == lon { return; // consecutive duplicate fix } } pts.push((lat, lon, ele)); } loop { match reader.read_event().map_err(|e| format!("{path}: {e}"))? { Event::Eof => break, Event::Empty(e) => { if e.local_name().as_ref() == b"trkpt" { let (la, lo) = read_latlon(&e); lat = la; lon = lo; ele = 0.0; push(&mut pts, lat, lon, ele); } } Event::Start(e) => { depth += 1; match e.local_name().as_ref() { b"trkpt" => { trkpt_depth = Some(depth); ele = 0.0; let (la, lo) = read_latlon(&e); lat = la; lon = lo; } // accept only as a direct child of , so // elevation-like tags inside vendor extensions are ignored b"ele" => { in_ele = trkpt_depth == Some(depth - 1); if in_ele { ele = 0.0; } } _ => {} } } Event::Text(t) => { if in_ele { if let Ok(s) = t.decode() { if let Ok(v) = s.trim().parse() { ele = v; } } } } Event::End(e) => { match e.local_name().as_ref() { b"ele" => in_ele = false, b"trkpt" => { push(&mut pts, lat, lon, ele); trkpt_depth = None; } _ => {} } depth = depth.saturating_sub(1); } _ => {} } } if pts.is_empty() { return Err(format!("{path}: no track points")); } let stride = pts.len().div_ceil(max_points.max(1)); Ok(pts .iter() .step_by(stride) .map(|&(lat, lon, ele)| { let mut h = Hill::new("track", lat.to_radians(), lon.to_radians(), ele); h.flags |= FLAG_TRACK_POINT; h }) .collect()) } fn read_latlon(e: &quick_xml::events::BytesStart) -> (f64, f64) { let (mut lat, mut lon) = (f64::NAN, f64::NAN); for a in e.attributes().flatten() { if let Ok(v) = a.normalized_value(quick_xml::XmlVersion::Implicit1_0) { match a.key.local_name().as_ref() { b"lat" => lat = v.trim().parse().unwrap_or(f64::NAN), b"lon" => lon = v.trim().parse().unwrap_or(f64::NAN), _ => {} } } } (lat, lon) } #[cfg(test)] mod tests { use super::*; fn tmp(name: &str, content: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("gipfel-gpx-test-{name}")); std::fs::create_dir_all(&dir).unwrap(); let p = dir.join("t.gpx"); std::fs::write(&p, content).unwrap(); p } #[test] fn gpx_track_roundtrip() { let gpx = r#" tour 1000 1100 1100 1200 "#; let p = tmp("rt", gpx); let hills = load_gpx(p.to_str().unwrap(), 100).unwrap(); assert_eq!(hills.len(), 3); // duplicate dropped assert!(hills.iter().all(|h| h.is_track_point())); assert!((hills[1].phi - 47.31f64.to_radians()).abs() < 1e-12); assert!((hills[2].height - 1200.0).abs() < 1e-9); } #[test] fn gpx_vendor_extensions_ignored() { // Garmin-style files are full of vendor extension blocks; they must // not confuse the parser (and must not contribute elevation). let gpx = r#" T9999 1000 120 1100 "#; let p = tmp("ext", gpx); let hills = load_gpx(p.to_str().unwrap(), 100).unwrap(); assert_eq!(hills.len(), 2); assert!((hills[0].height - 1000.0).abs() < 1e-9); // not 9999 } #[test] fn gpx_decimation() { let mut pts = String::new(); for i in 0..1000 { pts.push_str(&format!( "1", 47.0 + i as f64 * 1e-5, 11.0 + i as f64 * 1e-5 )); } let gpx = format!( r#"{pts}"# ); let p = tmp("dec", &gpx); let hills = load_gpx(p.to_str().unwrap(), 100).unwrap(); assert!(hills.len() <= 100); } #[test] fn gpx_bom_is_tolerated() { // Garmin Desktop App writes a UTF-8 BOM; it must not break parsing. let gpx = format!( "\u{feff}" ); let p = tmp("bom", &gpx); let hills = load_gpx(p.to_str().unwrap(), 100).unwrap(); assert_eq!(hills.len(), 1); } #[test] fn gpx_entity_injection_is_inert() { // XXE / entity-expansion attempts: quick-xml only expands the five // predefined entities; unknown references make the attribute fail to // parse, so the point is simply skipped (never expanded, never fetched). let gpx = r#" ]> 1000 1100 1200 "#; let p = tmp("ent", gpx); let hills = load_gpx(p.to_str().unwrap(), 100).unwrap(); // only the clean point survives; the entity-bearing ones are skipped assert_eq!(hills.len(), 1); assert!((hills[0].phi - 47.32f64.to_radians()).abs() < 1e-12); } }