diff options
| author | Johannes Hofmann <Johannes.Hofmann@gmx.de> | 2026-10-26 20:05:20 +0100 |
|---|---|---|
| committer | Johannes Hofmann <Johannes.Hofmann@gmx.de> | 2026-10-26 20:05:20 +0100 |
| commit | 375d87a9b0ec1cf2fffd705adb870c86144d2400 (patch) | |
| tree | 1e8e05e7cd58686628d0f4622622456613a42e4d /crates/gipfel-core/src/track.rs | |
| parent | 45425dd047b791fe63f13a46a3251c529cfffc94 (diff) | |
Replace the C++ implementation with the Rust port
The Rust port (formerly gipfel-rs) is now the official successor to the
FLTK/GSL C++ code base. This squashes the port into a single commit and
removes the C++ sources (they remain in the git history below).
Layout:
crates/gipfel-core headless core: hills, panorama geometry, projections
(maxima-generated derivatives), pure-Rust LM fit,
gipfel: JPEG comment + EXIF I/O, profiles, stitching
crates/gipfel-cli command-line tools (export, info, stitch, profiles)
crates/gipfel-app Cosmic DE (libcosmic) GUI, binary name `gipfel`
codegen/ maxima scripts + regen.sh for the projection code
tests/ golden files + C++ reference harnesses (historical)
The original README is kept for now; it will be updated to describe the
Rust build. gipfel.dat, AUTHORS, ChangeLog and NEWS are unchanged.
Diffstat (limited to 'crates/gipfel-core/src/track.rs')
| -rw-r--r-- | crates/gipfel-core/src/track.rs | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/crates/gipfel-core/src/track.rs b/crates/gipfel-core/src/track.rs new file mode 100644 index 0000000..5450aa8 --- /dev/null +++ b/crates/gipfel-core/src/track.rs @@ -0,0 +1,248 @@ +//! 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 +//! `<extensions>` 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 <ele> 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<Vec<Hill>, 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<usize> = 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 <ele> only as a direct child of <trkpt>, 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#"<?xml version="1.0" encoding="UTF-8"?> +<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"> + <trk><name>tour</name><trkseg> + <trkpt lat="47.30" lon="11.10"><ele>1000</ele></trkpt> + <trkpt lat="47.31" lon="11.11"><ele>1100</ele></trkpt> + <trkpt lat="47.31" lon="11.11"><ele>1100</ele></trkpt> + <trkpt lat="47.32" lon="11.12"><ele>1200</ele></trkpt> + </trkseg></trk> +</gpx>"#; + 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#"<?xml version="1.0" encoding="utf-8"?> +<gpx creator="Garmin Desktop App" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" + xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"> + <metadata><extensions><gpxx:TrackPointExtension xmlns:gpxx="x" version="1.0"/></extensions></metadata> + <trk><name>T</name><extensions><gpxx2:x xmlns:gpxx2="y"><ele>9999</ele></gpxx2:x></extensions> + <trkseg> + <trkpt lat="47.30" lon="11.10"> + <ele>1000</ele> + <extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>120</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions> + </trkpt> + <trkpt lat="47.31" lon="11.11"><ele>1100</ele></trkpt> + </trkseg> + </trk> +</gpx>"#; + 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!( + "<trkpt lat=\"{}\" lon=\"{}\"><ele>1</ele></trkpt>", + 47.0 + i as f64 * 1e-5, + 11.0 + i as f64 * 1e-5 + )); + } + let gpx = format!( + r#"<?xml version="1.0"?><gpx version="1.1" creator="t" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>{pts}</trkseg></trk></gpx>"# + ); + 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}<?xml version=\"1.0\"?><gpx version=\"1.1\" creator=\"t\" xmlns=\"http://www.topografix.com/GPX/1/1\"><trk><trkseg><trkpt lat=\"47.30\" lon=\"11.10\"/></trkseg></trk></gpx>" + ); + 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#"<?xml version="1.0"?> +<!DOCTYPE gpx [ <!ENTITY evil "47.30"> <!ENTITY sys SYSTEM "file:///etc/passwd"> ]> +<gpx version="1.1" creator="t" xmlns="http://www.topografix.com/GPX/1/1"> + <trk><trkseg> + <trkpt lat="&evil;" lon="11.10"><ele>1000</ele></trkpt> + <trkpt lat="47.31" lon="&sys;"><ele>1100</ele></trkpt> + <trkpt lat="47.32" lon="11.12"><ele>1200</ele></trkpt> + </trkseg></trk> +</gpx>"#; + 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); + } +} |
