Skip to main content

xisf_header/
reader.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Parsing: preamble validation and XML extraction into a [`Header`].
6
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11use quick_xml::events::{BytesStart, Event};
12use quick_xml::{Reader, XmlVersion};
13
14use crate::error::{Error, Result};
15use crate::header::Header;
16use crate::keyword::{is_commentary, FitsKeyword};
17use crate::property::Property;
18use crate::value::Value;
19
20/// The 8-byte XISF monolithic-file signature.
21pub(crate) const SIGNATURE: &[u8; 8] = b"XISF0100";
22
23/// Upper bound on the declared XML-header length (8 MiB).
24pub(crate) const MAX_HEADER_LEN: usize = 8 * 1024 * 1024;
25
26impl Header {
27    /// Parse an XISF header from raw container bytes.
28    ///
29    /// Validates the 16-byte preamble — bytes 0–7 are the `XISF0100`
30    /// signature, bytes 8–11 are the little-endian XML-header length (capped at
31    /// 8 MiB), bytes 12–15 are reserved and ignored — then decodes the UTF-8 XML
32    /// header and extracts every `<FITSKeyword>` and `<Property>`. Pixel/
33    /// attachment data beyond the header is never read.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`Error::TooSmall`] if the input is truncated,
38    /// [`Error::InvalidSignature`] on a bad signature, [`Error::HeaderTooLarge`]
39    /// if the declared header exceeds the cap, or an XML/UTF-8 error if the
40    /// header itself is malformed.
41    ///
42    /// ```
43    /// use xisf_header::{Header, StructuralHints};
44    ///
45    /// let mut header = Header::new();
46    /// header.set("IMAGETYP", "Master Dark")?;
47    ///
48    /// let bytes = header.to_header_bytes(&StructuralHints::default());
49    /// let parsed = Header::parse(&bytes)?;
50    /// assert_eq!(parsed.get_str("IMAGETYP")?, Some("Master Dark"));
51    /// # Ok::<(), xisf_header::Error>(())
52    /// ```
53    pub fn parse(bytes: &[u8]) -> Result<Self> {
54        let (start, end) = split_preamble(bytes)?;
55        let xml = std::str::from_utf8(&bytes[start..end])?;
56        parse_xml(xml)
57    }
58
59    /// Read and parse the header of an XISF file, reading only the preamble and
60    /// XML header — never the pixel/attachment payload.
61    ///
62    /// # Errors
63    ///
64    /// Propagates I/O errors and any [`Header::parse`] error.
65    ///
66    /// ```
67    /// use xisf_header::{Header, StructuralHints};
68    ///
69    /// let path = std::env::temp_dir().join("xisf-header-doctest-read.xisf");
70    /// let mut header = Header::new();
71    /// header.set("IMAGETYP", "Master Dark")?;
72    /// std::fs::write(&path, header.to_header_bytes(&StructuralHints::default()))?;
73    ///
74    /// let reloaded = Header::read_from_file(&path)?;
75    /// assert_eq!(reloaded.get_str("IMAGETYP")?, Some("Master Dark"));
76    /// # std::fs::remove_file(&path).ok();
77    /// # Ok::<(), xisf_header::Error>(())
78    /// ```
79    pub fn read_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
80        let mut file = File::open(path)?;
81
82        let mut preamble = [0u8; 16];
83        file.read_exact(&mut preamble)?;
84        if &preamble[0..8] != SIGNATURE {
85            return Err(Error::InvalidSignature);
86        }
87        let xml_len =
88            u32::from_le_bytes([preamble[8], preamble[9], preamble[10], preamble[11]]) as usize;
89        if xml_len > MAX_HEADER_LEN {
90            return Err(Error::HeaderTooLarge {
91                len: xml_len,
92                max: MAX_HEADER_LEN,
93            });
94        }
95
96        let mut buf = vec![0u8; 16 + xml_len];
97        buf[..16].copy_from_slice(&preamble);
98        file.read_exact(&mut buf[16..])?;
99        Self::parse(&buf)
100    }
101}
102
103/// Validate the 16-byte preamble and return the byte range `(start, end)` of
104/// the UTF-8 XML header within `bytes` (`start` is always 16).
105pub(crate) fn split_preamble(bytes: &[u8]) -> Result<(usize, usize)> {
106    if bytes.len() < 16 {
107        return Err(Error::TooSmall {
108            needed: 16,
109            got: bytes.len(),
110        });
111    }
112    if &bytes[0..8] != SIGNATURE {
113        return Err(Error::InvalidSignature);
114    }
115    let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
116    // bytes[12..16] are reserved and ignored on read.
117    if xml_len > MAX_HEADER_LEN {
118        return Err(Error::HeaderTooLarge {
119            len: xml_len,
120            max: MAX_HEADER_LEN,
121        });
122    }
123    let end = 16 + xml_len;
124    if bytes.len() < end {
125        return Err(Error::TooSmall {
126            needed: end,
127            got: bytes.len(),
128        });
129    }
130    Ok((16, end))
131}
132
133/// A `<Property>` opened as a start tag, which may carry its value as child
134/// text (the XISF long form for `String` properties) instead of a `value`
135/// attribute.
136struct OpenProperty {
137    id: Option<String>,
138    prop: Property,
139    has_value_attr: bool,
140    /// Byte offset (into the XML `&str`) where the opening `<Property` tag
141    /// began, so [`XmlIndex::property_spans`] can record the whole element.
142    span_start: usize,
143}
144
145/// Byte spans of the modeled elements in a parsed XML header, plus the
146/// single attachment location if the layout is one [`crate::writer`]'s
147/// byte-exact `update_file` splice can target. Spans are byte offsets into
148/// the XML `&str` passed to [`parse_xml_with_index`].
149pub(crate) struct XmlIndex {
150    /// Span of each named `<FITSKeyword>` element, in document order —
151    /// aligned 1:1 with [`Header::keywords`] by index, since both are built
152    /// by the same skip-if-nameless traversal.
153    pub keyword_spans: Vec<(usize, usize)>,
154    /// Span of each id'd `<Property>` element, in document order, paired
155    /// with its id (nameless `<Property>`s are skipped, like keywords).
156    pub property_spans: Vec<(String, usize, usize)>,
157    /// The data-bearing element's location and insertion point, or an error
158    /// reason when the document's attachment layout isn't one the splice
159    /// path can safely target.
160    pub image: std::result::Result<ImageInfo, String>,
161}
162
163/// Where a single `<Image location="attachment:OFFSET:SIZE">` element's
164/// attachment lives, and where to splice in newly-appended elements.
165pub(crate) struct ImageInfo {
166    /// Span of the `attachment:OFFSET:SIZE` text (excluding the surrounding
167    /// quotes) within the `location` attribute.
168    pub location_span: (usize, usize),
169    pub offset: usize,
170    pub size: usize,
171    /// Byte offset just before `</Image>`, where new `<FITSKeyword>`/
172    /// `<Property>` elements are appended. `None` when `<Image>` is
173    /// self-closing and so has no child content to insert into.
174    pub insertion_point: Option<usize>,
175}
176
177/// Extract keywords and properties from the decoded XML header, and
178/// [`XmlIndex`] and [`Header`] together.
179fn parse_xml(xml: &str) -> Result<Header> {
180    parse_xml_with_index(xml).map(|(header, _)| header)
181}
182
183/// Like [`parse_xml`], but also records the byte spans needed to splice a
184/// byte-exact update (see [`crate::splice`]).
185pub(crate) fn parse_xml_with_index(xml: &str) -> Result<(Header, XmlIndex)> {
186    let mut reader = Reader::from_str(xml);
187    let mut header = Header::new();
188    let mut open_property: Option<OpenProperty> = None;
189    let mut keyword_spans = Vec::new();
190    let mut property_spans = Vec::new();
191
192    let mut image_count = 0usize;
193    let mut image_end_start: Option<usize> = None;
194    // (is_image, location value span, offset, size), for every element
195    // (other than FITSKeyword/Property) carrying a valid `attachment:N:N`
196    // `location` attribute — used to reject layouts with zero or multiple
197    // attachments.
198    let mut locations: Vec<(bool, usize, usize, usize, usize)> = Vec::new();
199
200    loop {
201        let start = reader.buffer_position() as usize;
202        let event = reader.read_event()?;
203        let end = reader.buffer_position() as usize;
204        match event {
205            Event::Empty(e) => {
206                let local = e.local_name();
207                let tag = local.as_ref();
208                if tag.eq_ignore_ascii_case(b"FITSKeyword") {
209                    if let Some(kw) = parse_keyword(&e)? {
210                        header.keywords.push(kw);
211                        keyword_spans.push((start, end));
212                    }
213                } else if tag.eq_ignore_ascii_case(b"Property") {
214                    let (id, prop, _) = parse_property(&e)?;
215                    if let Some(id) = id {
216                        property_spans.push((id.clone(), start, end));
217                        header.properties.insert(id, prop);
218                    }
219                } else {
220                    if tag.eq_ignore_ascii_case(b"Image") {
221                        image_count += 1;
222                    }
223                    record_location(xml, start, end, tag, &mut locations);
224                }
225            }
226            Event::Start(e) => {
227                let local = e.local_name();
228                let tag = local.as_ref();
229                if tag.eq_ignore_ascii_case(b"FITSKeyword") {
230                    if let Some(kw) = parse_keyword(&e)? {
231                        header.keywords.push(kw);
232                        keyword_spans.push((start, end));
233                    }
234                } else if tag.eq_ignore_ascii_case(b"Property") {
235                    let (id, prop, has_value_attr) = parse_property(&e)?;
236                    open_property = Some(OpenProperty {
237                        id,
238                        prop,
239                        has_value_attr,
240                        span_start: start,
241                    });
242                } else {
243                    if tag.eq_ignore_ascii_case(b"Image") {
244                        image_count += 1;
245                    }
246                    record_location(xml, start, end, tag, &mut locations);
247                }
248            }
249            Event::Text(t) => {
250                if let Some(open) = open_property.as_mut() {
251                    if !open.has_value_attr {
252                        let text = t
253                            .xml_content(XmlVersion::Implicit1_0)
254                            .map_err(quick_xml::Error::from)?;
255                        open.prop.value.push_str(&text);
256                    }
257                }
258            }
259            Event::CData(c) => {
260                if let Some(open) = open_property.as_mut() {
261                    if !open.has_value_attr {
262                        let text = c.decode().map_err(quick_xml::Error::from)?;
263                        open.prop.value.push_str(&text);
264                    }
265                }
266            }
267            Event::End(e) => {
268                let local = e.local_name();
269                let tag = local.as_ref();
270                if tag.eq_ignore_ascii_case(b"Property") {
271                    if let Some(open) = open_property.take() {
272                        if let Some(id) = open.id {
273                            property_spans.push((id.clone(), open.span_start, end));
274                            header.properties.insert(id, open.prop);
275                        }
276                    }
277                } else if tag.eq_ignore_ascii_case(b"Image") {
278                    image_end_start = Some(start);
279                }
280            }
281            Event::Eof => break,
282            _ => {}
283        }
284    }
285
286    let image = resolve_image(image_count, &locations, image_end_start);
287    Ok((
288        header,
289        XmlIndex {
290            keyword_spans,
291            property_spans,
292            image,
293        },
294    ))
295}
296
297/// If `tag` carries a `location="attachment:OFFSET:SIZE"` attribute, record
298/// it. Only called for elements other than `FITSKeyword`/`Property`, which
299/// never legitimately carry `location` (and whose attribute *values* could
300/// otherwise coincidentally contain the text `location=`).
301fn record_location(
302    xml: &str,
303    start: usize,
304    end: usize,
305    tag: &[u8],
306    locations: &mut Vec<(bool, usize, usize, usize, usize)>,
307) {
308    let Some((value_start, value_end)) =
309        find_attr_value_span(&xml.as_bytes()[start..end], b"location")
310    else {
311        return;
312    };
313    let (abs_start, abs_end) = (start + value_start, start + value_end);
314    let Some((offset, size)) = xml
315        .get(abs_start..abs_end)
316        .and_then(parse_attachment_location)
317    else {
318        return;
319    };
320    locations.push((
321        tag.eq_ignore_ascii_case(b"Image"),
322        abs_start,
323        abs_end,
324        offset,
325        size,
326    ));
327}
328
329/// Decide whether the document has a splice-able single attachment: exactly
330/// one `<Image>` element, carrying the document's one `location="attachment:
331/// …">` attribute.
332fn resolve_image(
333    image_count: usize,
334    locations: &[(bool, usize, usize, usize, usize)],
335    image_end_start: Option<usize>,
336) -> std::result::Result<ImageInfo, String> {
337    if image_count == 0 {
338        return Err("no <Image> element found".to_owned());
339    }
340    if image_count > 1 {
341        return Err(format!(
342            "found {image_count} <Image> elements; update_file supports exactly one"
343        ));
344    }
345    if locations.len() != 1 {
346        return Err(format!(
347            "found {} attachment location(s); update_file supports exactly one",
348            locations.len()
349        ));
350    }
351    let (is_image, value_start, value_end, offset, size) = locations[0];
352    if !is_image {
353        return Err("the attachment location is not on the <Image> element".to_owned());
354    }
355    Ok(ImageInfo {
356        location_span: (value_start, value_end),
357        offset,
358        size,
359        insertion_point: image_end_start,
360    })
361}
362
363/// Parse an `attachment:OFFSET:SIZE` location value.
364fn parse_attachment_location(value: &str) -> Option<(usize, usize)> {
365    let rest = value.strip_prefix("attachment:")?;
366    let (offset, size) = rest.split_once(':')?;
367    Some((offset.parse().ok()?, size.parse().ok()?))
368}
369
370/// Find the byte span of `attr_name`'s value (excluding quotes) within a
371/// single start-tag's raw bytes, case-insensitive on the name. XISF
372/// attribute values never contain markup, so a plain scan (rather than a
373/// full attribute tokenizer) is sufficient.
374fn find_attr_value_span(tag: &[u8], attr_name: &[u8]) -> Option<(usize, usize)> {
375    let mut i = 0;
376    while i + attr_name.len() <= tag.len() {
377        if !tag[i..i + attr_name.len()].eq_ignore_ascii_case(attr_name) {
378            i += 1;
379            continue;
380        }
381        let before_ok = i == 0 || tag[i - 1].is_ascii_whitespace();
382        let mut j = i + attr_name.len();
383        if before_ok {
384            while j < tag.len() && tag[j].is_ascii_whitespace() {
385                j += 1;
386            }
387            if j < tag.len() && tag[j] == b'=' {
388                j += 1;
389                while j < tag.len() && tag[j].is_ascii_whitespace() {
390                    j += 1;
391                }
392                if let Some(&quote) = tag.get(j).filter(|&&b| b == b'"' || b == b'\'') {
393                    let value_start = j + 1;
394                    if let Some(rel_end) = tag[value_start..].iter().position(|&b| b == quote) {
395                        return Some((value_start, value_start + rel_end));
396                    }
397                }
398            }
399        }
400        i += attr_name.len();
401    }
402    None
403}
404
405/// Read a `<FITSKeyword name= value= comment=>` element. An element without a
406/// `name` attribute yields `None`: a nameless keyword cannot be addressed and
407/// is skipped, like a `<Property>` without an `id`.
408///
409/// `HISTORY`/`COMMENT` are FITS commentary keywords with no value — their
410/// free text is read from `comment` (the correct, spec-conformant XISF form:
411/// `value="" comment="text"`). If `comment` is empty, fall back to `value`
412/// (unquoted) so files this crate wrote before this fix — which quoted the
413/// text into `value` — still read their text back; see [`is_commentary`].
414fn parse_keyword(e: &BytesStart) -> Result<Option<FitsKeyword>> {
415    let mut name = String::new();
416    let mut raw_value = String::new();
417    let mut comment = String::new();
418    for attr in e.attributes() {
419        let attr = attr?;
420        let value = attr.normalized_value(XmlVersion::Implicit1_0)?;
421        match attr.key.as_ref() {
422            k if k.eq_ignore_ascii_case(b"name") => name = value.into_owned(),
423            k if k.eq_ignore_ascii_case(b"value") => raw_value = value.into_owned(),
424            k if k.eq_ignore_ascii_case(b"comment") => comment = value.into_owned(),
425            _ => {}
426        }
427    }
428    if name.is_empty() {
429        return Ok(None);
430    }
431    let (value, comment) = if is_commentary(&name) {
432        if comment.is_empty() {
433            (classify_value(&raw_value), String::new())
434        } else {
435            (Value::Str(comment), String::new())
436        }
437    } else {
438        (classify_value(&raw_value), comment)
439    };
440    Ok(Some(FitsKeyword {
441        name,
442        value,
443        comment,
444    }))
445}
446
447/// Read a `<Property>` element's attributes: `id`, `type`, `value`,
448/// `comment`, and `format`, all kept verbatim (XISF property values are not
449/// FITS-quoted). Returns the id (if any), the property, and whether a `value`
450/// attribute was present (when absent, the value may follow as child text).
451fn parse_property(e: &BytesStart) -> Result<(Option<String>, Property, bool)> {
452    let mut id = None;
453    let mut prop = Property::default();
454    let mut has_value_attr = false;
455    for attr in e.attributes() {
456        let attr = attr?;
457        let raw = attr.normalized_value(XmlVersion::Implicit1_0)?;
458        match attr.key.as_ref() {
459            k if k.eq_ignore_ascii_case(b"id") => id = Some(raw.into_owned()),
460            k if k.eq_ignore_ascii_case(b"type") => prop.type_ = raw.into_owned(),
461            k if k.eq_ignore_ascii_case(b"value") => {
462                prop.value = raw.into_owned();
463                has_value_attr = true;
464            }
465            k if k.eq_ignore_ascii_case(b"comment") => prop.comment = raw.into_owned(),
466            k if k.eq_ignore_ascii_case(b"format") => prop.format = raw.into_owned(),
467            _ => {}
468        }
469    }
470    Ok((id, prop, has_value_attr))
471}
472
473/// Classify a keyword value attribute: single-quote-wrapped text is a string
474/// value (one quote layer stripped); anything else is a bare literal.
475fn classify_value(text: &str) -> Value {
476    let bytes = text.as_bytes();
477    if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' {
478        Value::Str(text[1..text.len() - 1].to_owned())
479    } else {
480        Value::Literal(text.to_owned())
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    /// Wrap XML in a valid preamble, with explicit reserved bytes.
489    fn container(xml: &str, reserved: [u8; 4]) -> Vec<u8> {
490        let mut out = Vec::new();
491        out.extend_from_slice(SIGNATURE);
492        out.extend_from_slice(&(u32::try_from(xml.len()).unwrap()).to_le_bytes());
493        out.extend_from_slice(&reserved);
494        out.extend_from_slice(xml.as_bytes());
495        out
496    }
497
498    #[test]
499    fn classify_quoted_and_bare() {
500        assert_eq!(classify_value("'M31'"), Value::Str("M31".to_owned()));
501        assert_eq!(classify_value("''"), Value::Str(String::new()));
502        // One quote layer is stripped, inner quotes stay.
503        assert_eq!(classify_value("'''"), Value::Str("'".to_owned()));
504        assert_eq!(classify_value("300"), Value::Literal("300".to_owned()));
505        assert_eq!(classify_value("'"), Value::Literal("'".to_owned()));
506        assert_eq!(classify_value(""), Value::Literal(String::new()));
507    }
508
509    #[test]
510    fn too_small_preamble() {
511        assert!(matches!(
512            Header::parse(b"XISF01"),
513            Err(Error::TooSmall { needed: 16, got: 6 })
514        ));
515    }
516
517    #[test]
518    fn too_small_for_declared_length() {
519        let mut bytes = container("<xisf/>", [0; 4]);
520        bytes[8..12].copy_from_slice(&100_u32.to_le_bytes()); // declare more than present
521        assert!(matches!(
522            Header::parse(&bytes),
523            Err(Error::TooSmall { needed: 116, .. })
524        ));
525    }
526
527    #[test]
528    fn header_too_large_is_rejected() {
529        let mut bytes = container("<xisf/>", [0; 4]);
530        let over = u32::try_from(MAX_HEADER_LEN + 1).unwrap();
531        bytes[8..12].copy_from_slice(&over.to_le_bytes());
532        assert!(matches!(
533            Header::parse(&bytes),
534            Err(Error::HeaderTooLarge { max, .. }) if max == MAX_HEADER_LEN
535        ));
536    }
537
538    #[test]
539    fn invalid_utf8_is_rejected() {
540        let mut bytes = container("<xisf></xisf>", [0; 4]);
541        bytes[20] = 0xFF;
542        assert!(matches!(Header::parse(&bytes), Err(Error::Utf8(_))));
543    }
544
545    #[test]
546    fn malformed_xml_is_rejected() {
547        let bytes = container("<xisf><Image></xisf>", [0; 4]);
548        assert!(matches!(Header::parse(&bytes), Err(Error::Xml(_))));
549    }
550
551    #[test]
552    fn reserved_bytes_are_ignored() {
553        let bytes = container("<xisf/>", [0xDE, 0xAD, 0xBE, 0xEF]);
554        assert!(Header::parse(&bytes).is_ok());
555    }
556
557    #[test]
558    fn attribute_names_are_case_insensitive() {
559        let xml = r#"<xisf><FITSKeyword NAME="GAIN" VALUE="100" COMMENT="c"/></xisf>"#;
560        let h = Header::parse(&container(xml, [0; 4])).unwrap();
561        assert_eq!(h.get_i64("GAIN").unwrap(), Some(100));
562        assert_eq!(h.keywords()[0].comment, "c");
563    }
564
565    #[test]
566    fn nameless_keywords_are_skipped() {
567        let xml = r#"<xisf>
568            <FITSKeyword value="'orphan'" comment="no name"/>
569            <FITSKeyword name="GAIN" value="100" comment=""/>
570        </xisf>"#;
571        let h = Header::parse(&container(xml, [0; 4])).unwrap();
572        assert_eq!(h.keywords().len(), 1);
573        assert_eq!(h.get_i64("GAIN").unwrap(), Some(100));
574    }
575
576    #[test]
577    fn commentary_keyword_reads_from_comment_attribute() {
578        // The canonical, spec-conformant form: no value, text in `comment`.
579        let xml = r#"<xisf><FITSKeyword name="HISTORY" value="" comment="processed in PixInsight"/></xisf>"#;
580        let h = Header::parse(&container(xml, [0; 4])).unwrap();
581        assert_eq!(
582            h.get_str("HISTORY").unwrap(),
583            Some("processed in PixInsight")
584        );
585    }
586
587    #[test]
588    fn commentary_keyword_falls_back_to_quoted_value_for_backward_compat() {
589        // Files this crate wrote before this fix quoted the text into
590        // `value` and left `comment` empty; those must keep reading.
591        let xml =
592            r#"<xisf><FITSKeyword name="HISTORY" value="&apos;old form&apos;" comment=""/></xisf>"#;
593        let h = Header::parse(&container(xml, [0; 4])).unwrap();
594        assert_eq!(h.get_str("HISTORY").unwrap(), Some("old form"));
595    }
596
597    #[test]
598    fn unknown_attributes_and_elements_are_skipped() {
599        let xml = r#"<xisf>
600            <Metadata><Property id="XISF:CreatorApplication" type="String" value="PixInsight"/></Metadata>
601            <Image geometry="256:256:1" sampleFormat="UInt16" colorSpace="Gray" location="attachment:4096:131072">
602                <FITSKeyword name="GAIN" value="100" comment="" unknown="x"/>
603                <Resolution horizontal="72" vertical="72"/>
604            </Image>
605        </xisf>"#;
606        let h = Header::parse(&container(xml, [0; 4])).unwrap();
607        assert_eq!(h.get_i64("GAIN").unwrap(), Some(100));
608        // Properties are collected wherever they appear in the header.
609        assert_eq!(h.property("XISF:CreatorApplication"), Some("PixInsight"));
610    }
611}