1use 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
20pub(crate) const SIGNATURE: &[u8; 8] = b"XISF0100";
22
23pub(crate) const MAX_HEADER_LEN: usize = 8 * 1024 * 1024;
25
26impl Header {
27 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 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
103pub(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 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
133struct OpenProperty {
137 id: Option<String>,
138 prop: Property,
139 has_value_attr: bool,
140 span_start: usize,
143}
144
145pub(crate) struct XmlIndex {
150 pub keyword_spans: Vec<(usize, usize)>,
154 pub property_spans: Vec<(String, usize, usize)>,
157 pub image: std::result::Result<ImageInfo, String>,
161}
162
163pub(crate) struct ImageInfo {
166 pub location_span: (usize, usize),
169 pub offset: usize,
170 pub size: usize,
171 pub insertion_point: Option<usize>,
175}
176
177fn parse_xml(xml: &str) -> Result<Header> {
180 parse_xml_with_index(xml).map(|(header, _)| header)
181}
182
183pub(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 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
297fn 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
329fn 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
363fn 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
370fn 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("e) = 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
405fn 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
447fn 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
473fn 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 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 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()); 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 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 let xml =
592 r#"<xisf><FITSKeyword name="HISTORY" value="'old form'" 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 assert_eq!(h.property("XISF:CreatorApplication"), Some("PixInsight"));
610 }
611}