Skip to main content

osmic_repl/
osc.rs

1use std::io::Read;
2use std::path::Path;
3
4use flate2::read::GzDecoder;
5use quick_xml::events::{BytesStart, Event};
6use quick_xml::Reader;
7
8use osmic_core::error::{OsmicError, OsmicResult};
9
10/// The type of change in an OSC file.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ChangeAction {
13    Create,
14    Modify,
15    Delete,
16}
17
18/// A parsed change from an OSC file.
19#[derive(Debug, Clone)]
20pub struct OscChange {
21    pub action: ChangeAction,
22    pub element: OscElement,
23}
24
25/// An OSM element from a change file.
26#[derive(Debug, Clone)]
27pub enum OscElement {
28    Node {
29        id: i64,
30        lon: f64,
31        lat: f64,
32        version: u32,
33        visible: bool,
34        tags: Vec<(String, String)>,
35    },
36    Way {
37        id: i64,
38        version: u32,
39        visible: bool,
40        node_refs: Vec<i64>,
41        tags: Vec<(String, String)>,
42    },
43    Relation {
44        id: i64,
45        version: u32,
46        visible: bool,
47        members: Vec<RelMember>,
48        tags: Vec<(String, String)>,
49    },
50}
51
52/// A relation member reference.
53#[derive(Debug, Clone)]
54pub struct RelMember {
55    pub member_type: String,
56    pub ref_id: i64,
57    pub role: String,
58}
59
60/// Transient element-being-parsed state for the OSC XML state machine.
61#[derive(Debug)]
62enum Parsing {
63    None,
64    Node {
65        id: i64,
66        lon: f64,
67        lat: f64,
68        version: u32,
69        visible: bool,
70    },
71    Way {
72        id: i64,
73        version: u32,
74        visible: bool,
75    },
76    Relation {
77        id: i64,
78        version: u32,
79        visible: bool,
80    },
81}
82
83impl OscElement {
84    pub fn id(&self) -> i64 {
85        match self {
86            OscElement::Node { id, .. } => *id,
87            OscElement::Way { id, .. } => *id,
88            OscElement::Relation { id, .. } => *id,
89        }
90    }
91}
92
93/// Parse an .osc.gz file from disk.
94pub fn parse_osc_gz(path: &Path) -> OsmicResult<Vec<OscChange>> {
95    let file = std::fs::File::open(path)
96        .map_err(|e| OsmicError::Other(format!("Failed to open {}: {e}", path.display())))?;
97    let decoder = GzDecoder::new(file);
98    parse_osc(decoder)
99}
100
101/// Parse an .osc.gz from raw bytes (e.g. downloaded from replication server).
102pub fn parse_osc_gz_bytes(data: &[u8]) -> OsmicResult<Vec<OscChange>> {
103    let decoder = GzDecoder::new(data);
104    parse_osc(decoder)
105}
106
107/// Parse OSC XML from any reader.
108pub fn parse_osc<R: Read>(reader: R) -> OsmicResult<Vec<OscChange>> {
109    let mut xml = Reader::from_reader(std::io::BufReader::new(reader));
110    xml.config_mut().trim_text(true);
111
112    let mut changes = Vec::new();
113    let mut current_action: Option<ChangeAction> = None;
114    let mut buf = Vec::new();
115
116    // Transient state for the element being parsed
117    let mut elem_tags: Vec<(String, String)> = Vec::new();
118    let mut elem_nd_refs: Vec<i64> = Vec::new();
119    let mut elem_members: Vec<RelMember> = Vec::new();
120    let mut parsing = Parsing::None;
121
122    loop {
123        let event = xml.read_event_into(&mut buf);
124        match event {
125            // Start and Empty share the same "open element" handling. For Empty
126            // (self-closing) elements we additionally finalize immediately since
127            // quick-xml will NOT fire a matching End event.
128            Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
129                let is_empty = matches!(event, Ok(Event::Empty(_)));
130                let qname = e.name();
131                let name_bytes = qname.as_ref();
132                let name = std::str::from_utf8(name_bytes).unwrap_or("");
133
134                match name {
135                    "create" => current_action = Some(ChangeAction::Create),
136                    "modify" => current_action = Some(ChangeAction::Modify),
137                    "delete" => current_action = Some(ChangeAction::Delete),
138                    "node" => {
139                        elem_tags.clear();
140                        parsing = Parsing::Node {
141                            id: attr_i64(e, "id"),
142                            lon: attr_f64(e, "lon"),
143                            lat: attr_f64(e, "lat"),
144                            version: attr_u32(e, "version"),
145                            visible: attr_bool(e, "visible"),
146                        };
147                    }
148                    "way" => {
149                        elem_tags.clear();
150                        elem_nd_refs.clear();
151                        parsing = Parsing::Way {
152                            id: attr_i64(e, "id"),
153                            version: attr_u32(e, "version"),
154                            visible: attr_bool(e, "visible"),
155                        };
156                    }
157                    "relation" => {
158                        elem_tags.clear();
159                        elem_members.clear();
160                        parsing = Parsing::Relation {
161                            id: attr_i64(e, "id"),
162                            version: attr_u32(e, "version"),
163                            visible: attr_bool(e, "visible"),
164                        };
165                    }
166                    "tag" => {
167                        let k = attr_str(e, "k");
168                        let v = attr_str(e, "v");
169                        elem_tags.push((k, v));
170                    }
171                    "nd" => {
172                        elem_nd_refs.push(attr_i64(e, "ref"));
173                    }
174                    "member" => {
175                        elem_members.push(RelMember {
176                            member_type: attr_str(e, "type"),
177                            ref_id: attr_i64(e, "ref"),
178                            role: attr_str(e, "role"),
179                        });
180                    }
181                    _ => {}
182                }
183
184                // Self-closing node/way/relation elements must be finalized here
185                // because no End event will arrive.
186                if is_empty && matches!(name, "node" | "way" | "relation") {
187                    if let Some(action) = current_action {
188                        if let Some(element) = finalize_element(
189                            &mut parsing,
190                            &mut elem_tags,
191                            &mut elem_nd_refs,
192                            &mut elem_members,
193                        ) {
194                            changes.push(OscChange { action, element });
195                        }
196                    }
197                    parsing = Parsing::None;
198                }
199            }
200            Ok(Event::End(ref e)) => {
201                let qname = e.name();
202                let name = std::str::from_utf8(qname.as_ref()).unwrap_or("");
203                match name {
204                    "create" | "modify" | "delete" => current_action = None,
205                    "node" | "way" | "relation" => {
206                        if let Some(action) = current_action {
207                            if let Some(element) = finalize_element(
208                                &mut parsing,
209                                &mut elem_tags,
210                                &mut elem_nd_refs,
211                                &mut elem_members,
212                            ) {
213                                changes.push(OscChange { action, element });
214                            }
215                        }
216                        parsing = Parsing::None;
217                    }
218                    _ => {}
219                }
220            }
221            // Reject DTDs outright — OSM replication files never contain them,
222            // and permitting them opens the door to XXE and billion-laughs attacks.
223            Ok(Event::DocType(_)) => {
224                return Err(OsmicError::Other(
225                    "DTD declarations are not allowed in OSC files".into(),
226                ));
227            }
228            // Reject user-defined entity references for the same reason. The
229            // five XML-predefined refs (&amp;lt; &amp;gt; &amp;amp; &amp;apos; &amp;quot;) and numeric
230            // character references are handled by BytesText::unescape() and
231            // never surface as GeneralRef events.
232            Ok(Event::GeneralRef(_)) => {
233                return Err(OsmicError::Other(
234                    "Entity references are not allowed in OSC files".into(),
235                ));
236            }
237            Ok(Event::Eof) => break,
238            Err(e) => {
239                return Err(OsmicError::Other(format!("XML parse error: {e}")));
240            }
241            _ => {}
242        }
243        buf.clear();
244    }
245
246    Ok(changes)
247}
248
249fn attr_str(e: &BytesStart, name: &str) -> String {
250    // Unescape predefined XML entities (&amp; &lt; &gt; &apos; &quot; and numeric
251    // character references). unescape_value() explicitly does NOT resolve
252    // user-defined entities — those surface as Event::GeneralRef and are
253    // rejected at the top of the parse loop for security.
254    e.attributes()
255        .flatten()
256        .find(|a| a.key.as_ref() == name.as_bytes())
257        .and_then(|a| a.unescape_value().ok().map(|c| c.into_owned()))
258        .unwrap_or_default()
259}
260
261fn attr_i64(e: &BytesStart, name: &str) -> i64 {
262    attr_str(e, name).parse().unwrap_or(0)
263}
264
265fn attr_u32(e: &BytesStart, name: &str) -> u32 {
266    attr_str(e, name).parse().unwrap_or(0)
267}
268
269fn attr_f64(e: &BytesStart, name: &str) -> f64 {
270    attr_str(e, name).parse().unwrap_or(0.0)
271}
272
273fn attr_bool(e: &BytesStart, name: &str) -> bool {
274    attr_str(e, name) != "false"
275}
276
277/// Consume the transient element-being-parsed state and produce the final
278/// `OscElement`. Returns `None` if we aren't currently parsing an element
279/// (e.g., a stray End event for an unknown element).
280fn finalize_element(
281    parsing: &mut Parsing,
282    elem_tags: &mut Vec<(String, String)>,
283    elem_nd_refs: &mut Vec<i64>,
284    elem_members: &mut Vec<RelMember>,
285) -> Option<OscElement> {
286    match std::mem::replace(parsing, Parsing::None) {
287        Parsing::Node {
288            id,
289            lon,
290            lat,
291            version,
292            visible,
293        } => Some(OscElement::Node {
294            id,
295            lon,
296            lat,
297            version,
298            visible,
299            tags: std::mem::take(elem_tags),
300        }),
301        Parsing::Way {
302            id,
303            version,
304            visible,
305        } => Some(OscElement::Way {
306            id,
307            version,
308            visible,
309            node_refs: std::mem::take(elem_nd_refs),
310            tags: std::mem::take(elem_tags),
311        }),
312        Parsing::Relation {
313            id,
314            version,
315            visible,
316        } => Some(OscElement::Relation {
317            id,
318            version,
319            visible,
320            members: std::mem::take(elem_members),
321            tags: std::mem::take(elem_tags),
322        }),
323        Parsing::None => None,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// A well-formed minimal OSC file should parse without error.
332    #[test]
333    fn parse_minimal_osc() {
334        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
335<osmChange version="0.6" generator="test">
336    <create>
337        <node id="1" lon="1.0" lat="2.0" version="1" visible="true"/>
338    </create>
339    <modify>
340        <node id="2" lon="3.0" lat="4.0" version="2" visible="true">
341            <tag k="name" v="test"/>
342        </node>
343    </modify>
344    <delete>
345        <node id="3" lon="5.0" lat="6.0" version="3" visible="false"/>
346    </delete>
347</osmChange>"#;
348        let changes = parse_osc(&xml[..]).expect("valid OSC must parse");
349        assert_eq!(changes.len(), 3);
350        assert_eq!(changes[0].action, ChangeAction::Create);
351        assert_eq!(changes[1].action, ChangeAction::Modify);
352        assert_eq!(changes[2].action, ChangeAction::Delete);
353    }
354
355    /// XXE attempt: DTD declaration with external entity referencing a local file.
356    /// Must be rejected before any entity expansion or file I/O can occur.
357    #[test]
358    fn reject_xxe_external_entity() {
359        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
360<!DOCTYPE osmChange [
361    <!ENTITY xxe SYSTEM "file:///etc/passwd">
362]>
363<osmChange version="0.6">
364    <modify>
365        <node id="1" lon="0" lat="0" version="1" visible="true">
366            <tag k="name" v="&xxe;"/>
367        </node>
368    </modify>
369</osmChange>"#;
370        let err = parse_osc(&xml[..]).expect_err("XXE payload must be rejected");
371        let msg = format!("{err}");
372        assert!(
373            msg.contains("DTD"),
374            "error should mention DTD rejection, got: {msg}"
375        );
376    }
377
378    /// Billion-laughs / quadratic blowup: nested DTD entity definitions.
379    /// Must be rejected up-front at the DOCTYPE event, not allowed to expand.
380    #[test]
381    fn reject_billion_laughs() {
382        let xml = br#"<?xml version="1.0"?>
383<!DOCTYPE osmChange [
384    <!ENTITY lol "lol">
385    <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
386    <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
387    <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
388]>
389<osmChange version="0.6">
390    <create>
391        <node id="1" lon="0" lat="0" version="1" visible="true">
392            <tag k="name" v="&lol4;"/>
393        </node>
394    </create>
395</osmChange>"#;
396        let err = parse_osc(&xml[..]).expect_err("billion-laughs payload must be rejected");
397        let msg = format!("{err}");
398        assert!(
399            msg.contains("DTD"),
400            "error should mention DTD rejection, got: {msg}"
401        );
402    }
403
404    /// The five predefined XML entities (lt, gt, amp, apos, quot) must still
405    /// work — they're unescaped by quick-xml itself, never surface as events.
406    #[test]
407    fn predefined_entities_allowed() {
408        let xml = br#"<?xml version="1.0"?>
409<osmChange version="0.6">
410    <create>
411        <node id="1" lon="0" lat="0" version="1" visible="true">
412            <tag k="name" v="Fish &amp; Chips"/>
413        </node>
414    </create>
415</osmChange>"#;
416        let changes = parse_osc(&xml[..]).expect("predefined entities must parse");
417        assert_eq!(changes.len(), 1);
418        if let OscElement::Node { tags, .. } = &changes[0].element {
419            assert_eq!(tags.len(), 1);
420            assert_eq!(tags[0].0, "name");
421            assert_eq!(tags[0].1, "Fish & Chips");
422        } else {
423            panic!("expected Node, got {:?}", changes[0].element);
424        }
425    }
426}