Skip to main content

asdf_core/
layout.rs

1//! Scanning an ASDF file's overall structure.
2//!
3//! The layout is, in order: a `#ASDF` header line, optional comment lines, an
4//! optional YAML tree, zero or more binary blocks, and an optional block
5//! index. The tree's length is deliberately not recorded anywhere, so that it
6//! can be edited by hand; readers find its end by searching for the document
7//! end marker.
8
9use core::ops::Range;
10
11use crate::block::header::{BLOCK_MAGIC, BlockHeader, is_block_magic};
12use crate::error::{Result, err};
13use crate::version::Version;
14
15/// The token every ASDF file starts with.
16pub const ASDF_HEADER_PREFIX: &[u8] = b"#ASDF ";
17/// The comment that records the ASDF Standard version.
18pub const ASDF_STANDARD_PREFIX: &[u8] = b"#ASDF_STANDARD ";
19/// The line that introduces the block index.
20pub const BLOCK_INDEX_HEADER: &[u8] = b"#ASDF BLOCK INDEX";
21/// The prefix of the YAML version directive.
22pub const YAML_DIRECTIVE_PREFIX: &[u8] = b"%YAML ";
23/// The YAML document end marker, including its leading newline.
24pub const YAML_DOCUMENT_END_MARKER: &[u8] = b"\n...";
25
26/// Where a block lives in the file.
27#[derive(Clone, PartialEq, Eq, Debug)]
28pub struct BlockLocation {
29    /// Zero-based index of the block in the file.
30    pub index: usize,
31    /// Byte offset of the block magic.
32    pub header_pos: u64,
33    /// Byte offset of the first data byte, just past the header.
34    pub data_pos: u64,
35    /// The decoded header.
36    pub header: BlockHeader,
37}
38
39impl BlockLocation {
40    /// The offset one past the block's allocated space, where the next block
41    /// or the block index begins.
42    ///
43    /// Saturating, because `allocated_size` comes straight from the file and
44    /// a corrupt one can be `u64::MAX`. Wrapping would produce an offset
45    /// *inside* the file and send the scanner somewhere plausible-looking;
46    /// saturating pushes it past the end, where the bounds check catches it.
47    pub fn end_pos(&self) -> u64 {
48        self.data_pos.saturating_add(self.header.allocated_size)
49    }
50}
51
52/// Why a block index was rejected.
53///
54/// The standard tells libraries to be conservative here: addressing the wrong
55/// part of a file on the strength of a stale index is worse than rebuilding it.
56#[derive(Clone, PartialEq, Eq, Debug)]
57pub enum IndexRejection {
58    /// The index YAML could not be parsed as a list of integers.
59    Unparseable,
60    /// The index listed a different number of blocks than the file contains.
61    CountMismatch {
62        /// Offsets listed in the index.
63        listed: usize,
64        /// Blocks actually found.
65        found: usize,
66    },
67    /// The first offset did not point at the first block. This catches the
68    /// common case of a tree edited by hand without updating the index.
69    FirstOffsetMismatch {
70        /// What the index claimed.
71        listed: u64,
72        /// Where the first block actually is.
73        actual: u64,
74    },
75    /// An offset did not point at block magic.
76    NotBlockMagic {
77        /// The offending offset.
78        offset: u64,
79    },
80    /// Offsets were not monotonically increasing, so the index could not be
81    /// rebuilt by skipping along.
82    NotMonotonic,
83    /// The last block's allocated space was not immediately followed by the
84    /// index.
85    LastBlockNotAdjacent,
86}
87
88/// The result of scanning a file.
89#[derive(Clone, Debug)]
90pub struct Layout {
91    /// The low-level format version from the `#ASDF` line.
92    pub format_version: Version,
93    /// The standard version from the `#ASDF_STANDARD` comment, if present.
94    pub standard_version: Option<Version>,
95    /// Any other comment lines between the header and the tree, without their
96    /// leading `#` or trailing newline.
97    pub comments: Vec<String>,
98    /// Byte range of the YAML tree, if the file has one.
99    pub tree: Option<Range<usize>>,
100    /// The blocks, in file order.
101    pub blocks: Vec<BlockLocation>,
102    /// Byte offset of the block index header, if one was found.
103    pub block_index_pos: Option<u64>,
104    /// The offsets listed in the block index, whether or not it was accepted.
105    ///
106    /// Kept even when the index is rejected: the low-level event API reports
107    /// what the file says, and leaves judging it to the reader.
108    pub block_index_offsets: Vec<u64>,
109    /// Why the block index was not used, if it was found but rejected.
110    pub index_rejection: Option<IndexRejection>,
111}
112
113impl Layout {
114    /// Whether the file carries a YAML tree.
115    ///
116    /// A file in exploded form may legitimately have none.
117    pub fn has_tree(&self) -> bool {
118        self.tree.is_some()
119    }
120
121    /// The tree text, given the buffer it was scanned from.
122    pub fn tree_str<'a>(&self, buf: &'a [u8]) -> Option<&'a str> {
123        let range = self.tree.clone()?;
124        core::str::from_utf8(&buf[range]).ok()
125    }
126
127    /// Whether a block index was present and accepted.
128    pub fn used_block_index(&self) -> bool {
129        self.block_index_pos.is_some() && self.index_rejection.is_none()
130    }
131}
132
133/// Find the end of a line starting at `pos`, returning the content without
134/// its line terminator and the offset of the next line.
135fn read_line(buf: &[u8], pos: usize) -> Option<(&[u8], usize)> {
136    if pos >= buf.len() {
137        return None;
138    }
139    match buf[pos..].iter().position(|b| *b == b'\n') {
140        Some(rel) => {
141            let nl = pos + rel;
142            // Trim a DOS line ending.
143            let end = if nl > pos && buf[nl - 1] == b'\r' { nl - 1 } else { nl };
144            Some((&buf[pos..end], nl + 1))
145        }
146        // A final line with no terminator.
147        None => Some((&buf[pos..], buf.len())),
148    }
149}
150
151/// Scan the header, comments and tree extent, returning the offset where the
152/// binary section begins.
153fn scan_text_section(buf: &[u8], out: &mut Layout) -> Result<usize> {
154    let Some((line, mut pos)) = read_line(buf, 0) else {
155        return Err(err!(InvalidAsdfHeader, "file is empty"));
156    };
157
158    if !line.starts_with(ASDF_HEADER_PREFIX) {
159        return Err(err!(
160            InvalidAsdfHeader,
161            "file does not begin with the {:?} token",
162            String::from_utf8_lossy(ASDF_HEADER_PREFIX)
163        ));
164    }
165    let version = core::str::from_utf8(&line[ASDF_HEADER_PREFIX.len()..])
166        .map_err(|_| err!(InvalidAsdfHeader, "ASDF version is not valid UTF-8"))?;
167    out.format_version = Version::parse(version.trim());
168
169    // Comment lines, up to the tree or the binary section.
170    while let Some((line, next)) = read_line(buf, pos) {
171        if !line.starts_with(b"#") {
172            break;
173        }
174        if let Some(rest) = line.strip_prefix(ASDF_STANDARD_PREFIX) {
175            if let Ok(s) = core::str::from_utf8(rest) {
176                out.standard_version = Some(Version::parse(s.trim()));
177            }
178        } else {
179            out.comments.push(String::from_utf8_lossy(&line[1..]).into_owned());
180        }
181        pos = next;
182    }
183
184    // What follows is either the tree, a block, or nothing.
185    if pos >= buf.len() {
186        return Ok(pos);
187    }
188    if is_block_magic(&buf[pos..]) {
189        return Ok(pos);
190    }
191    if !buf[pos..].starts_with(YAML_DIRECTIVE_PREFIX) {
192        // Not a directive and not a block: treat the remainder as a tree
193        // anyway if it looks like YAML content, otherwise as binary.
194        if !buf[pos..].starts_with(b"---") {
195            return Ok(pos);
196        }
197    }
198
199    let tree_start = pos;
200    let tree_end = find_document_end(buf, tree_start);
201    out.tree = Some(tree_start..tree_end);
202    Ok(tree_end)
203}
204
205/// Find the end of the YAML document beginning at `start`.
206///
207/// The standard's recommended search is for `\r?\n...\r?\n`. The returned
208/// offset is just past the marker's own line terminator, so the tree range
209/// includes the `...` line.
210fn find_document_end(buf: &[u8], start: usize) -> usize {
211    let mut search = start;
212    while let Some(rel) = find_bytes(&buf[search..], YAML_DOCUMENT_END_MARKER) {
213        let marker = search + rel;
214        let after = marker + YAML_DOCUMENT_END_MARKER.len();
215        // The marker must be alone on its line.
216        match buf.get(after) {
217            None => return buf.len(),
218            Some(b'\n') => return after + 1,
219            Some(b'\r') if buf.get(after + 1) == Some(&b'\n') => return after + 2,
220            _ => search = after,
221        }
222    }
223    // No end marker: the tree runs to the first block, or to EOF.
224    match find_bytes(&buf[start..], BLOCK_MAGIC) {
225        Some(rel) => start + rel,
226        None => buf.len(),
227    }
228}
229
230/// A plain substring search over bytes.
231fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
232    if needle.is_empty() || haystack.len() < needle.len() {
233        return None;
234    }
235    haystack.windows(needle.len()).position(|w| w == needle)
236}
237
238/// The same search, from the end.
239fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
240    if needle.is_empty() || haystack.len() < needle.len() {
241        return None;
242    }
243    haystack.windows(needle.len()).rposition(|w| w == needle)
244}
245
246/// Walk the blocks from `pos`, following each header's allocated size.
247///
248/// This is the "skip along" traversal the standard describes, and is what
249/// makes the block index optional.
250fn scan_blocks(buf: &[u8], mut pos: usize, out: &mut Layout) -> Result<()> {
251    // Padding may separate the tree from the first block.
252    if !is_block_magic(buf.get(pos..).unwrap_or(&[])) {
253        match find_bytes(&buf[pos.min(buf.len())..], BLOCK_MAGIC) {
254            Some(rel) => pos += rel,
255            None => return Ok(()),
256        }
257    }
258
259    while pos < buf.len() {
260        if !is_block_magic(&buf[pos..]) {
261            break;
262        }
263        let (header, consumed) = BlockHeader::parse(&buf[pos..])?;
264        let data_pos = pos + consumed;
265
266        let location = BlockLocation {
267            index: out.blocks.len(),
268            header_pos: pos as u64,
269            data_pos: data_pos as u64,
270            header: header.clone(),
271        };
272
273        if header.is_streamed() {
274            // A streamed block runs to the end of the file, and nothing may
275            // follow it.
276            out.blocks.push(location);
277            return Ok(());
278        }
279
280        let end = location.end_pos();
281        if end > buf.len() as u64 {
282            return Err(err!(
283                UnexpectedEof,
284                "block {} claims {} bytes but the file ends at {}",
285                location.index,
286                header.allocated_size,
287                buf.len()
288            ));
289        }
290        out.blocks.push(location);
291        pos = end as usize;
292    }
293    Ok(())
294}
295
296/// Parse the block index's YAML payload: a flow or block sequence of integers.
297fn parse_index_offsets(text: &str) -> Option<Vec<u64>> {
298    let mut offsets = Vec::new();
299    let mut saw_any = false;
300
301    for raw in text.lines() {
302        let line = raw.trim();
303        if line.is_empty()
304            || line.starts_with('%')
305            || line == "---"
306            || line == "..."
307            || line.starts_with('#')
308        {
309            continue;
310        }
311        // Block style: "- 1234"
312        if let Some(rest) = line.strip_prefix("- ") {
313            offsets.push(rest.trim().parse::<u64>().ok()?);
314            saw_any = true;
315            continue;
316        }
317        // Flow style, possibly following a document marker: "--- [1, 2]"
318        let body = line.strip_prefix("---").unwrap_or(line).trim();
319        let body = body.strip_prefix('[')?.strip_suffix(']')?;
320        for part in body.split(',') {
321            let p = part.trim();
322            if p.is_empty() {
323                continue;
324            }
325            offsets.push(p.parse::<u64>().ok()?);
326        }
327        saw_any = true;
328    }
329
330    saw_any.then_some(offsets)
331}
332
333/// Look for a block index at the end of the file and check it against the
334/// blocks actually found.
335fn scan_block_index(buf: &[u8], out: &mut Layout) {
336    // The standard says to read backwards from the end.
337    let Some(pos) = rfind_bytes(buf, BLOCK_INDEX_HEADER) else {
338        return;
339    };
340    out.block_index_pos = Some(pos as u64);
341
342    let text = String::from_utf8_lossy(&buf[pos + BLOCK_INDEX_HEADER.len()..]);
343    let Some(offsets) = parse_index_offsets(&text) else {
344        out.index_rejection = Some(IndexRejection::Unparseable);
345        return;
346    };
347    out.block_index_offsets.clone_from(&offsets);
348
349    if offsets.windows(2).any(|w| w[1] <= w[0]) {
350        out.index_rejection = Some(IndexRejection::NotMonotonic);
351        return;
352    }
353    if offsets.len() != out.blocks.len() {
354        out.index_rejection =
355            Some(IndexRejection::CountMismatch { listed: offsets.len(), found: out.blocks.len() });
356        return;
357    }
358    if let (Some(first_listed), Some(first_block)) = (offsets.first(), out.blocks.first())
359        && *first_listed != first_block.header_pos
360    {
361        out.index_rejection = Some(IndexRejection::FirstOffsetMismatch {
362            listed: *first_listed,
363            actual: first_block.header_pos,
364        });
365        return;
366    }
367    for off in &offsets {
368        let ok = usize::try_from(*off).ok().and_then(|o| buf.get(o..)).is_some_and(is_block_magic);
369        if !ok {
370            out.index_rejection = Some(IndexRejection::NotBlockMagic { offset: *off });
371            return;
372        }
373    }
374    if let Some(last) = out.blocks.last()
375        && last.end_pos() != pos as u64
376    {
377        out.index_rejection = Some(IndexRejection::LastBlockNotAdjacent);
378    }
379}
380
381/// Scan an in-memory ASDF file.
382pub fn scan(buf: &[u8]) -> Result<Layout> {
383    let mut out = Layout {
384        format_version: Version::default(),
385        standard_version: None,
386        comments: Vec::new(),
387        tree: None,
388        blocks: Vec::new(),
389        block_index_pos: None,
390        block_index_offsets: Vec::new(),
391        index_rejection: None,
392    };
393
394    let after_text = scan_text_section(buf, &mut out)?;
395    scan_blocks(buf, after_text, &mut out)?;
396    scan_block_index(buf, &mut out);
397    Ok(out)
398}
399
400/// Render a block index for a set of blocks, as written at the end of a file.
401pub fn write_block_index(offsets: &[u64]) -> Vec<u8> {
402    let mut out = Vec::new();
403    out.extend_from_slice(BLOCK_INDEX_HEADER);
404    out.push(b'\n');
405    out.extend_from_slice(b"%YAML 1.1\n---\n");
406    for off in offsets {
407        out.extend_from_slice(format!("- {off}\n").as_bytes());
408    }
409    out.extend_from_slice(b"...\n");
410    out
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::block::header::BLOCK_HEADER_FULL_SIZE;
417    use crate::block::header::FLAG_STREAMED;
418    use crate::error::ErrorCode;
419
420    /// Build a minimal but well-formed ASDF file.
421    fn build(tree: Option<&str>, block_payloads: &[&[u8]], with_index: bool) -> Vec<u8> {
422        let mut buf = Vec::new();
423        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
424        if let Some(t) = tree {
425            buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
426            buf.extend_from_slice(t.as_bytes());
427            buf.extend_from_slice(b"\n...\n");
428        }
429        let mut offsets = Vec::new();
430        for payload in block_payloads {
431            offsets.push(buf.len() as u64);
432            let h = BlockHeader {
433                allocated_size: payload.len() as u64,
434                used_size: payload.len() as u64,
435                data_size: payload.len() as u64,
436                ..Default::default()
437            };
438            h.write(&mut buf);
439            buf.extend_from_slice(payload);
440        }
441        if with_index && !offsets.is_empty() {
442            buf.extend_from_slice(&write_block_index(&offsets));
443        }
444        buf
445    }
446
447    #[test]
448    fn reads_header_and_standard_version() {
449        let buf = build(Some("foo: 1"), &[], false);
450        let l = scan(&buf).unwrap();
451        assert_eq!(l.format_version.triple(), (1, 0, 0));
452        assert_eq!(l.standard_version.unwrap().triple(), (1, 6, 0));
453    }
454
455    #[test]
456    fn rejects_a_file_without_the_asdf_token() {
457        let e = scan(b"not an asdf file\n").unwrap_err();
458        assert_eq!(e.code(), ErrorCode::InvalidAsdfHeader);
459        assert_eq!(scan(b"").unwrap_err().code(), ErrorCode::InvalidAsdfHeader);
460    }
461
462    #[test]
463    fn finds_the_tree_extent() {
464        let buf = build(Some("foo: 1"), &[], false);
465        let l = scan(&buf).unwrap();
466        let tree = l.tree_str(&buf).unwrap();
467        assert!(tree.starts_with("%YAML 1.1\n"));
468        assert!(tree.trim_end().ends_with("..."));
469        assert!(tree.contains("foo: 1"));
470    }
471
472    #[test]
473    fn handles_dos_line_endings() {
474        let mut buf = Vec::new();
475        buf.extend_from_slice(b"#ASDF 1.0.0\r\n#ASDF_STANDARD 1.6.0\r\n");
476        buf.extend_from_slice(b"%YAML 1.1\r\n--- !core/asdf-1.1.0\r\nfoo: 1\r\n...\r\n");
477        let l = scan(&buf).unwrap();
478        assert_eq!(l.format_version.triple(), (1, 0, 0));
479        assert_eq!(l.standard_version.as_ref().unwrap().triple(), (1, 6, 0));
480        assert!(l.has_tree());
481        assert!(l.tree_str(&buf).unwrap().contains("foo: 1"));
482    }
483
484    #[test]
485    fn collects_other_comments() {
486        let mut buf = Vec::new();
487        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n# a note\n");
488        buf.extend_from_slice(b"%YAML 1.1\n--- !core/asdf-1.1.0\nfoo: 1\n...\n");
489        let l = scan(&buf).unwrap();
490        assert_eq!(l.comments, [" a note"]);
491    }
492
493    #[test]
494    fn a_file_may_have_no_tree() {
495        // Exploded form: header then straight into blocks.
496        let buf = build(None, &[b"abcd"], false);
497        let l = scan(&buf).unwrap();
498        assert!(!l.has_tree());
499        assert_eq!(l.blocks.len(), 1);
500    }
501
502    #[test]
503    fn walks_blocks_by_skipping_along() {
504        let buf = build(Some("x: 1"), &[b"aaaa", b"bbbbbbbb", b"c"], false);
505        let l = scan(&buf).unwrap();
506        assert_eq!(l.blocks.len(), 3);
507        assert_eq!(l.blocks[0].header.used_size, 4);
508        assert_eq!(l.blocks[1].header.used_size, 8);
509        assert_eq!(l.blocks[2].header.used_size, 1);
510
511        // Each block's data must sit where the header says it does.
512        for (b, expect) in l.blocks.iter().zip([&b"aaaa"[..], b"bbbbbbbb", b"c"]) {
513            let start = b.data_pos as usize;
514            let end = start + b.header.used_size as usize;
515            assert_eq!(&buf[start..end], expect);
516        }
517    }
518
519    #[test]
520    fn tolerates_padding_between_tree_and_first_block() {
521        let mut buf = build(Some("x: 1"), &[], false);
522        buf.extend_from_slice(&[b' '; 64]); // the spec suggests spaces
523        let block_at = buf.len() as u64;
524        let h = BlockHeader { allocated_size: 4, used_size: 4, data_size: 4, ..Default::default() };
525        h.write(&mut buf);
526        buf.extend_from_slice(b"data");
527
528        let l = scan(&buf).unwrap();
529        assert_eq!(l.blocks.len(), 1);
530        assert_eq!(l.blocks[0].header_pos, block_at);
531    }
532
533    #[test]
534    fn accepts_a_correct_block_index() {
535        let buf = build(Some("x: 1"), &[b"aaaa", b"bbbb"], true);
536        let l = scan(&buf).unwrap();
537        assert_eq!(l.blocks.len(), 2);
538        assert!(l.used_block_index(), "index should be accepted: {:?}", l.index_rejection);
539    }
540
541    #[test]
542    fn rejects_an_index_whose_first_offset_is_stale() {
543        // The case the standard singles out: the tree was edited by hand and
544        // every offset shifted.
545        let mut buf = build(Some("x: 1"), &[b"aaaa"], true);
546        let idx = rfind_bytes(&buf, BLOCK_INDEX_HEADER).unwrap();
547        let tail = write_block_index(&[9999]);
548        buf.truncate(idx);
549        buf.extend_from_slice(&tail);
550
551        let l = scan(&buf).unwrap();
552        assert!(!l.used_block_index());
553        assert!(matches!(l.index_rejection, Some(IndexRejection::FirstOffsetMismatch { .. })));
554        // ...but the blocks are still found by skipping along.
555        assert_eq!(l.blocks.len(), 1);
556    }
557
558    #[test]
559    fn rejects_a_non_monotonic_index() {
560        let mut buf = build(Some("x: 1"), &[b"aaaa", b"bbbb"], false);
561        buf.extend_from_slice(&write_block_index(&[500, 100]));
562        let l = scan(&buf).unwrap();
563        assert_eq!(l.index_rejection, Some(IndexRejection::NotMonotonic));
564    }
565
566    #[test]
567    fn rejects_an_index_with_the_wrong_count() {
568        let mut buf = build(Some("x: 1"), &[b"aaaa", b"bbbb"], false);
569        let first = buf.windows(4).position(|w| w == BLOCK_MAGIC).unwrap() as u64;
570        buf.extend_from_slice(&write_block_index(&[first]));
571        let l = scan(&buf).unwrap();
572        assert!(matches!(
573            l.index_rejection,
574            Some(IndexRejection::CountMismatch { listed: 1, found: 2 })
575        ));
576    }
577
578    #[test]
579    fn parses_both_index_styles() {
580        // Block style, as libasdf writes it.
581        assert_eq!(
582            parse_index_offsets("%YAML 1.1\n---\n- 901\n- 1024\n...\n"),
583            Some(vec![901, 1024])
584        );
585        // Flow style, as the standard's example shows.
586        assert_eq!(
587            parse_index_offsets("%YAML 1.1\n--- [2043, 16340]\n...\n"),
588            Some(vec![2043, 16340])
589        );
590    }
591
592    #[test]
593    fn streamed_block_ends_the_scan() {
594        let mut buf = build(Some("x: 1"), &[], false);
595        let h = BlockHeader { flags: FLAG_STREAMED, ..Default::default() };
596        h.write(&mut buf);
597        buf.extend_from_slice(b"streaming payload, length unknown up front");
598
599        let l = scan(&buf).unwrap();
600        assert_eq!(l.blocks.len(), 1);
601        assert!(l.blocks[0].header.is_streamed());
602    }
603
604    #[test]
605    fn block_running_past_eof_is_an_error() {
606        let mut buf = build(Some("x: 1"), &[], false);
607        let h = BlockHeader {
608            allocated_size: 1_000_000,
609            used_size: 1_000_000,
610            data_size: 1_000_000,
611            ..Default::default()
612        };
613        h.write(&mut buf);
614        buf.extend_from_slice(b"short");
615        assert_eq!(scan(&buf).unwrap_err().code(), ErrorCode::UnexpectedEof);
616    }
617
618    #[test]
619    fn index_round_trips() {
620        let rendered = write_block_index(&[901, 2048]);
621        let text = String::from_utf8(rendered.clone()).unwrap();
622        assert!(text.starts_with("#ASDF BLOCK INDEX\n"));
623        let body = &text[BLOCK_INDEX_HEADER.len()..];
624        assert_eq!(parse_index_offsets(body), Some(vec![901, 2048]));
625        assert_eq!(BLOCK_HEADER_FULL_SIZE, 54);
626    }
627}