Skip to main content

xisf_header/
reader.rs

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