Skip to main content

asdf_core/
reader.rs

1//! Reading an ASDF file: its tree, and the data in its binary blocks.
2
3use alloc::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6use asdf_yaml::{Document, parse_document};
7
8use crate::block::header::CHECKSUM_SIZE;
9use crate::compression::Compression;
10use crate::error::{Result, err};
11use crate::layout::{BlockLocation, Layout, scan};
12
13/// Where a reader's bytes come from.
14enum Source {
15    /// A memory-mapped file. Block data is read straight out of the mapping,
16    /// so a large array costs no copy until it is decompressed or converted.
17    ///
18    /// Absent under Miri, which cannot execute `mmap`: `Reader::open` reads
19    /// the file whole there instead, so nothing would construct this and
20    /// `dead_code` would fire.
21    #[cfg(not(miri))]
22    Mapped(memmap2::Mmap),
23    /// An in-memory buffer.
24    Owned(Vec<u8>),
25}
26
27impl core::ops::Deref for Source {
28    type Target = [u8];
29    fn deref(&self) -> &[u8] {
30        match self {
31            #[cfg(not(miri))]
32            Source::Mapped(m) => m,
33            Source::Owned(v) => v,
34        }
35    }
36}
37
38impl core::fmt::Debug for Source {
39    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40        let kind = match self {
41            #[cfg(not(miri))]
42            Source::Mapped(_) => "Mapped",
43            Source::Owned(_) => "Owned",
44        };
45        write!(f, "{kind}({} bytes)", self.len())
46    }
47}
48
49/// The outcome of verifying a block's checksum.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum ChecksumStatus {
52    /// The header records no checksum; the all-zero value means "do not
53    /// verify", so this is not a failure.
54    Absent,
55    /// The recorded digest matches.
56    Valid,
57    /// The recorded digest does not match.
58    Invalid,
59}
60
61impl ChecksumStatus {
62    /// Whether this status should be treated as a failure.
63    ///
64    /// An absent checksum is not one: the standard makes it optional.
65    pub fn is_failure(self) -> bool {
66        self == ChecksumStatus::Invalid
67    }
68}
69
70/// The relative path an external `source` URI names, if it names a safe one.
71///
72/// Rejects anything that could escape the referring file's directory: an
73/// absolute path, a `..` component, a Windows drive or UNC prefix, or a URI
74/// with a scheme or authority. `file:` is not special-cased -- a relative
75/// path is the only form the reference corpus uses and the only one worth
76/// the risk.
77fn external_relative_path(uri: &str) -> Result<PathBuf> {
78    if uri.is_empty() {
79        return Err(err!(InvalidArgument, "external source is an empty URI"));
80    }
81    if uri.contains("://") || uri.starts_with('/') || uri.starts_with('\\') {
82        return Err(err!(
83            InvalidArgument,
84            "external source {uri:?} is not a relative path; only files beside the \
85             referring one can be resolved"
86        ));
87    }
88
89    // Percent-decoding is deliberately not done: a URI needing it is not one
90    // the corpus produces, and decoding would reopen the escape it rejects.
91    let path = Path::new(uri);
92    for component in path.components() {
93        use std::path::Component;
94        match component {
95            Component::Normal(_) | Component::CurDir => {}
96            Component::ParentDir => {
97                return Err(err!(
98                    InvalidArgument,
99                    "external source {uri:?} climbs out of the referring file's directory"
100                ));
101            }
102            Component::RootDir | Component::Prefix(_) => {
103                return Err(err!(InvalidArgument, "external source {uri:?} is not relative"));
104            }
105        }
106    }
107    Ok(path.to_path_buf())
108}
109
110/// An open ASDF file.
111#[derive(Debug)]
112pub struct Reader {
113    source: Source,
114    layout: Layout,
115    /// Where the file came from, when it came from disk.
116    ///
117    /// Kept so that an array whose `source` names another file -- exploded
118    /// form -- can be resolved relative to this one, as the standard says.
119    path: Option<PathBuf>,
120}
121
122impl Reader {
123    /// Open and scan a file from disk.
124    ///
125    /// The file is memory-mapped, so block data is not read until it is used.
126    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
127        let path = path.as_ref();
128
129        // Miri interprets rather than executes, so it has no `mmap`. Reading
130        // the file whole is observably the same to every caller -- `Source`
131        // hands out a `&[u8]` either way -- and it is what lets the FFI layer,
132        // which is where the unsafe code actually lives, be checked at all.
133        #[cfg(miri)]
134        {
135            let bytes = std::fs::read(path)?;
136            let layout = scan(&bytes)?;
137            return Ok(Self {
138                source: Source::Owned(bytes),
139                layout,
140                path: Some(path.to_path_buf()),
141            });
142        }
143
144        #[cfg(not(miri))]
145        {
146            let file = std::fs::File::open(path)?;
147            Self::map(file, path)
148        }
149    }
150
151    /// The memory-mapping half of [`Reader::open`], split out so the `miri`
152    /// fallback above stays readable.
153    #[cfg(not(miri))]
154    fn map(file: std::fs::File, path: &Path) -> Result<Self> {
155        // SAFETY: the only unsafe operation in the engine. Mapping is unsafe
156        // because another process truncating the file can turn a later read
157        // into SIGBUS. That hazard is inherent to memory-mapping and is the
158        // same one libasdf accepts; ASDF files are written whole rather than
159        // modified in place, so a concurrent truncation is not a case the
160        // format contemplates. Mapping is what lets a multi-gigabyte array be
161        // read without loading the file into memory, which is the point of
162        // the format.
163        #[allow(unsafe_code)]
164        let mapped = unsafe { memmap2::Mmap::map(&file) }?;
165        let layout = scan(&mapped)?;
166        Ok(Self { source: Source::Mapped(mapped), layout, path: Some(path.to_path_buf()) })
167    }
168
169    /// Scan an in-memory file.
170    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
171        let layout = scan(&bytes)?;
172        Ok(Self { source: Source::Owned(bytes), layout, path: None })
173    }
174
175    /// The path this file was opened from, if it came from disk.
176    pub fn path(&self) -> Option<&Path> {
177        self.path.as_deref()
178    }
179
180    /// The whole file's bytes.
181    pub fn bytes(&self) -> &[u8] {
182        &self.source
183    }
184
185    /// The scanned layout.
186    pub fn layout(&self) -> &Layout {
187        &self.layout
188    }
189
190    /// The YAML tree's text, if the file has a tree.
191    pub fn tree_text(&self) -> Option<&str> {
192        self.layout.tree_str(&self.source)
193    }
194
195    /// Parse the YAML tree.
196    ///
197    /// A file with no tree -- legitimate in exploded form -- yields `None`.
198    pub fn tree(&self) -> Result<Option<Document>> {
199        match self.tree_text() {
200            None => Ok(None),
201            Some(text) => Ok(Some(parse_document(text)?)),
202        }
203    }
204
205    /// The number of binary blocks.
206    pub fn block_count(&self) -> usize {
207        self.layout.blocks.len()
208    }
209
210    /// A block's location and header.
211    pub fn block(&self, index: usize) -> Result<&BlockLocation> {
212        self.layout.blocks.get(index).ok_or_else(|| {
213            err!(
214                InvalidArgument,
215                "block index {index} is out of range; the file has {} blocks",
216                self.layout.blocks.len()
217            )
218        })
219    }
220
221    /// A block's bytes exactly as stored, without decompressing.
222    ///
223    /// For an uncompressed block this is the data itself; for a compressed
224    /// one it is the compressed form.
225    pub fn block_raw(&self, index: usize) -> Result<&[u8]> {
226        let block = self.block(index)?;
227        let start = usize::try_from(block.data_pos)
228            .map_err(|_| err!(UnexpectedEof, "block {index} data offset overflows"))?;
229
230        let len = if block.header.is_streamed() {
231            // A streamed block runs to the end of the file; its size fields
232            // are meaningless.
233            self.source.len().saturating_sub(start)
234        } else {
235            usize::try_from(block.header.used_size)
236                .map_err(|_| err!(UnexpectedEof, "block {index} used_size overflows"))?
237        };
238
239        // Checked, since both `start` and `len` come from the file and a
240        // corrupt header can make the sum wrap.
241        let end = start
242            .checked_add(len)
243            .ok_or_else(|| err!(UnexpectedEof, "block {index} size overflows"))?;
244        self.source
245            .get(start..end)
246            .ok_or_else(|| err!(UnexpectedEof, "block {index} extends past the end of the file"))
247    }
248
249    /// The compression method a block uses.
250    pub fn block_compression(&self, index: usize) -> Result<Compression> {
251        Compression::from_name(self.block(index)?.header.compression_name())
252    }
253
254    /// A block's data, decompressed if necessary.
255    ///
256    /// An uncompressed block borrows straight from the file with no copy.
257    pub fn block_data(&self, index: usize) -> Result<Cow<'_, [u8]>> {
258        let raw = self.block_raw(index)?;
259        let compression = self.block_compression(index)?;
260        if compression == Compression::None {
261            return Ok(Cow::Borrowed(raw));
262        }
263        let expected = usize::try_from(self.block(index)?.header.data_size)
264            .map_err(|_| err!(UnexpectedEof, "block {index} data_size overflows"))?;
265        Ok(Cow::Owned(compression.decompress(raw, expected)?))
266    }
267
268    /// Verify a block's MD5 checksum.
269    ///
270    /// The returned digest is what the data actually hashes to, which is
271    /// useful for reporting a mismatch.
272    ///
273    /// # The Python asdf compatibility case
274    ///
275    /// For a *compressed* block, the specification means the checksum to
276    /// cover the bytes as stored. Python asdf 5.x and earlier instead
277    /// checksum the *uncompressed* data
278    /// ([asdf#2015](https://github.com/asdf-format/asdf/issues/2015)).
279    /// libasdf works around this by consulting the file's `asdf_library`
280    /// metadata, and so do we: see [`Reader::has_python_checksum_bug`]. A
281    /// compressed block whose stored bytes do not match is therefore retried
282    /// against the decompressed bytes when the writer is known to be affected.
283    pub fn verify_block_checksum(
284        &self,
285        index: usize,
286    ) -> Result<(ChecksumStatus, [u8; CHECKSUM_SIZE])> {
287        let header = &self.block(index)?.header;
288        if !header.has_checksum() {
289            return Ok((ChecksumStatus::Absent, [0; CHECKSUM_SIZE]));
290        }
291        let expected = header.checksum;
292
293        let raw_digest = md5_of(self.block_raw(index)?);
294        if raw_digest == expected {
295            return Ok((ChecksumStatus::Valid, raw_digest));
296        }
297
298        // Only compressed blocks are affected, and only when the writer is
299        // one of the versions known to be wrong.
300        if self.block_compression(index)? != Compression::None && self.has_python_checksum_bug() {
301            let decompressed = md5_of(&self.block_data(index)?);
302            if decompressed == expected {
303                return Ok((ChecksumStatus::Valid, decompressed));
304            }
305        }
306
307        Ok((ChecksumStatus::Invalid, raw_digest))
308    }
309
310    /// Whether this file was written by a Python asdf version that
311    /// checksums compressed blocks incorrectly.
312    ///
313    /// Matches libasdf's test: the `asdf_library` name is `asdf` and its
314    /// major version is 5 or below.
315    pub fn has_python_checksum_bug(&self) -> bool {
316        const BUGGY_THROUGH_MAJOR: u32 = 5;
317
318        let Ok(Some(doc)) = self.tree() else { return false };
319        let Some(root) = doc.root() else { return false };
320        let Some(library) = doc.mapping_get(root, "asdf_library") else {
321            return false;
322        };
323
324        let name = doc
325            .mapping_get(library, "name")
326            .and_then(|id| doc.resolved(id).as_str().map(str::to_string));
327        if name.as_deref() != Some("asdf") {
328            return false;
329        }
330
331        doc.mapping_get(library, "version")
332            .and_then(|id| doc.resolved(id).as_str().map(crate::Version::parse))
333            .is_some_and(|v| v.major <= BUGGY_THROUGH_MAJOR)
334    }
335}
336
337/// MD5 of a buffer.
338fn md5_of(data: &[u8]) -> [u8; CHECKSUM_SIZE] {
339    use md5::{Digest, Md5};
340    let mut hasher = Md5::new();
341    hasher.update(data);
342    hasher.finalize().into()
343}
344
345/// Walking a tree to find every node carrying a given tag name.
346///
347/// The version suffix is ignored, so `core/ndarray-1.0.0` and
348/// `core/ndarray-1.1.0` both match `core/ndarray`.
349fn find_tagged(doc: &Document, name: &str) -> Vec<asdf_yaml::NodeId> {
350    use asdf_yaml::NodeData;
351
352    let mut out = Vec::new();
353    let mut seen = std::collections::HashSet::new();
354    let Some(root) = doc.root() else { return out };
355    let mut stack = vec![root];
356
357    while let Some(id) = stack.pop() {
358        let resolved = doc.resolve(id);
359        if !seen.insert(resolved) {
360            continue;
361        }
362        if doc.tag_of(resolved).is_some_and(|t| t.split_version().0 == name) {
363            out.push(resolved);
364        }
365        match &doc.node(resolved).data {
366            NodeData::Sequence { items, .. } => stack.extend(items.iter().copied()),
367            NodeData::Mapping { entries, .. } => {
368                stack.extend(entries.iter().map(|e| e.value));
369            }
370            _ => {}
371        }
372    }
373    out.sort();
374    out
375}
376
377impl Reader {
378    /// Resolve an ndarray's `source` to a block index in this file.
379    fn block_index_for(&self, source: &crate::core::ndarray::Source) -> Option<usize> {
380        match source {
381            crate::core::ndarray::Source::Block(i) => Some(*i),
382            crate::core::ndarray::Source::LastBlock => self.block_count().checked_sub(1),
383            _ => None,
384        }
385    }
386
387    /// Resolve an external array `source` and read the data it names.
388    ///
389    /// The standard makes `source` a URI relative to the file's own, and
390    /// exploded form writes one array per file with the data in block 0.
391    ///
392    /// Resolution is deliberately narrow. The URI must be a relative path
393    /// with no `..` component and no scheme, so a file can only reach others
394    /// beneath its own directory: a tree is untrusted input, and following an
395    /// arbitrary path out of it would let a crafted file name anything on the
396    /// machine. A file read from memory has no directory to resolve against
397    /// and so resolves nothing.
398    pub fn external_block(&self, uri: &str) -> Result<Vec<u8>> {
399        let Some(base) = self.path.as_deref().and_then(Path::parent) else {
400            return Err(err!(
401                InvalidArgument,
402                "external source {uri:?} cannot be resolved: this file was not read from disk"
403            ));
404        };
405        let relative = external_relative_path(uri)?;
406        let target = base.join(relative);
407
408        // The lexical check above stops `..` and absolute paths, but a
409        // symlink is neither: `data.bin -> /etc/shadow` is a clean relative
410        // name that `File::open` follows straight out of the directory.
411        // Resolving both sides and comparing is the only check that sees it.
412        let resolved = target.canonicalize().map_err(|e| {
413            err!(InvalidArgument, "external source {uri:?} ({}): {e}", target.display())
414        })?;
415        let root = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
416        if !resolved.starts_with(&root) {
417            return Err(err!(
418                InvalidArgument,
419                "external source {uri:?} resolves to {}, outside the referring file's \
420                 directory {}",
421                resolved.display(),
422                root.display()
423            ));
424        }
425
426        let referenced = Reader::open(&resolved).map_err(|e| {
427            err!(InvalidArgument, "external source {uri:?} ({}): {e}", target.display())
428        })?;
429        if referenced.block_count() == 0 {
430            return Err(err!(InvalidArgument, "external source {uri:?} has no blocks"));
431        }
432        Ok(referenced.block_data(0)?.into_owned())
433    }
434
435    /// Parse the tree with every block-backed `core/ndarray` replaced by its
436    /// data inline.
437    ///
438    /// This is the transformation the ASDF Standard's reference corpus asks
439    /// for before comparing a file against its expected YAML. An array whose
440    /// data lives in another file -- exploded form's external `source` -- is
441    /// resolved through [`Reader::external_block`], which needs this file to
442    /// have been read from disk.
443    ///
444    /// Returns the transformed tree and the paths of any arrays that could
445    /// not be inlined.
446    pub fn tree_inlined(&self) -> Result<Option<(Document, Vec<String>)>> {
447        use crate::core::elements::{decode_all, inline_ndarray};
448        use crate::core::ndarray::Ndarray;
449
450        let Some(mut doc) = self.tree()? else { return Ok(None) };
451        let mut skipped = Vec::new();
452
453        for id in find_tagged(&doc, "core/ndarray") {
454            let nd = match Ndarray::parse(&doc, id) {
455                Ok(nd) => nd,
456                Err(e) => {
457                    skipped.push(format!("{id:?}: {e}"));
458                    continue;
459                }
460            };
461
462            // Inline data is already where it needs to be.
463            if matches!(nd.source, crate::core::ndarray::Source::Inline(_)) {
464                continue;
465            }
466
467            // An external source names another file; its first block holds
468            // the data, which is how exploded form is written.
469            let data = if let crate::core::ndarray::Source::External(uri) = &nd.source {
470                match self.external_block(uri) {
471                    Ok(bytes) => Cow::Owned(bytes),
472                    Err(e) => {
473                        skipped.push(format!("{id:?}: {e}"));
474                        continue;
475                    }
476                }
477            } else {
478                let Some(index) = self.block_index_for(&nd.source) else {
479                    skipped.push(format!("{id:?}: data is outside this file ({:?})", nd.source));
480                    continue;
481                };
482                match self.block_data(index) {
483                    Ok(d) => d,
484                    Err(e) => {
485                        skipped.push(format!("{id:?}: block {index}: {e}"));
486                        continue;
487                    }
488                }
489            };
490
491            let shape = match nd.resolved_shape(Some(data.len() as u64)) {
492                Ok(s) => s,
493                Err(e) => {
494                    skipped.push(format!("{id:?}: {e}"));
495                    continue;
496                }
497            };
498
499            match decode_all(&nd, &shape, &data) {
500                Ok(elements) => inline_ndarray(&mut doc, id, &elements, &shape)?,
501                Err(e) => skipped.push(format!("{id:?}: {e}")),
502            }
503        }
504        Ok(Some((doc, skipped)))
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::block::header::BlockHeader;
512    use crate::layout::write_block_index;
513
514    /// Build a file with one block, optionally compressed and checksummed.
515    fn build(payload: &[u8], compression: Compression, checksum_over: Option<&[u8]>) -> Vec<u8> {
516        let stored = compression.compress(payload).unwrap();
517        let mut buf = Vec::new();
518        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
519        buf.extend_from_slice(
520            b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\nx: 1\n...\n",
521        );
522
523        let mut header = BlockHeader {
524            allocated_size: stored.len() as u64,
525            used_size: stored.len() as u64,
526            data_size: payload.len() as u64,
527            ..Default::default()
528        };
529        header.set_compression(compression.name()).unwrap();
530        if let Some(over) = checksum_over {
531            header.checksum = md5_of(over);
532        }
533
534        let offset = buf.len() as u64;
535        header.write(&mut buf);
536        buf.extend_from_slice(&stored);
537        buf.extend_from_slice(&write_block_index(&[offset]));
538        buf
539    }
540
541    #[test]
542    fn external_source_uris_may_not_escape_the_directory() {
543        // The only shape the standard's exploded form uses.
544        assert!(external_relative_path("exploded0000.asdf").is_ok());
545        assert!(external_relative_path("data/block0.asdf").is_ok());
546        assert!(external_relative_path("./here.asdf").is_ok());
547
548        // Everything that could reach outside the referring file's tree.
549        for bad in [
550            "",
551            "/etc/passwd",
552            "../secrets.asdf",
553            "data/../../secrets.asdf",
554            "file:///etc/passwd",
555            "https://example.invalid/x.asdf",
556        ] {
557            assert!(
558                external_relative_path(bad).is_err(),
559                "{bad:?} should be rejected as an external source"
560            );
561        }
562    }
563
564    #[test]
565    fn a_memory_backed_file_resolves_no_external_sources() {
566        let file = build(b"whatever", Compression::None, None);
567        let r = Reader::from_bytes(file);
568        let r = r.unwrap();
569        assert!(r.path().is_none());
570        // There is no directory to resolve against, so this must fail rather
571        // than guess at the working directory.
572        assert!(r.external_block("other.asdf").is_err());
573    }
574
575    #[test]
576    fn an_external_source_is_read_from_the_neighbouring_file() {
577        let dir = std::env::temp_dir().join(format!("asdf-exploded-{}", std::process::id()));
578        std::fs::create_dir_all(&dir).unwrap();
579
580        // The data file: one block holding four little-endian int32s.
581        let payload: Vec<u8> = [1i32, 2, 3, 4].iter().flat_map(|v| v.to_le_bytes()).collect();
582        let data_file = dir.join("holder0000.asdf");
583        std::fs::write(&data_file, build(&payload, Compression::None, None)).unwrap();
584
585        // The referring file: a tree naming it, and no blocks of its own.
586        let mut buf = Vec::new();
587        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
588        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
589        buf.extend_from_slice(
590            b"data: !core/ndarray-1.1.0\n  source: holder0000.asdf\n  \
591              datatype: int32\n  byteorder: little\n  shape: [4]\n",
592        );
593        buf.extend_from_slice(b"...\n");
594        let referring = dir.join("holder.asdf");
595        std::fs::write(&referring, buf).unwrap();
596
597        let r = Reader::open(&referring).unwrap();
598        assert_eq!(r.block_count(), 0, "the referring file holds no blocks itself");
599        assert_eq!(r.external_block("holder0000.asdf").unwrap(), payload);
600
601        // And inlining follows the reference through to real values.
602        let (doc, skipped) = r.tree_inlined().unwrap().unwrap();
603        assert!(skipped.is_empty(), "nothing should be left un-inlined: {skipped:?}");
604        let root = doc.root().unwrap();
605        let array = doc.mapping_get(root, "data").unwrap();
606        let values = doc.mapping_get(array, "data").unwrap();
607        let items = doc.sequence_items(values).unwrap();
608        let read: Vec<&str> = items.iter().map(|i| doc.resolved(*i).as_str().unwrap()).collect();
609        assert_eq!(read, ["1", "2", "3", "4"]);
610        // `source` is replaced, as it is for an internal block.
611        assert!(doc.mapping_get(array, "source").is_none());
612
613        std::fs::remove_dir_all(&dir).ok();
614    }
615
616    #[test]
617    fn a_missing_external_file_is_reported_not_silently_skipped() {
618        let dir =
619            std::env::temp_dir().join(format!("asdf-exploded-missing-{}", std::process::id()));
620        std::fs::create_dir_all(&dir).unwrap();
621        let referring = dir.join("dangling.asdf");
622        let mut buf = Vec::new();
623        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
624        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
625        buf.extend_from_slice(
626            b"data: !core/ndarray-1.1.0\n  source: nowhere0000.asdf\n  \
627              datatype: int32\n  byteorder: little\n  shape: [4]\n",
628        );
629        buf.extend_from_slice(b"...\n");
630        std::fs::write(&referring, buf).unwrap();
631
632        let r = Reader::open(&referring).unwrap();
633        let (_, skipped) = r.tree_inlined().unwrap().unwrap();
634        assert_eq!(skipped.len(), 1);
635        assert!(skipped[0].contains("nowhere0000.asdf"), "{skipped:?}");
636
637        std::fs::remove_dir_all(&dir).ok();
638    }
639
640    #[test]
641    fn reads_tree_and_block_data() {
642        let payload = b"hello block data".to_vec();
643        let file = build(&payload, Compression::None, None);
644        let r = Reader::from_bytes(file).unwrap();
645
646        assert_eq!(r.block_count(), 1);
647        assert_eq!(&*r.block_data(0).unwrap(), &payload[..]);
648
649        let doc = r.tree().unwrap().unwrap();
650        let root = doc.root().unwrap();
651        assert!(doc.mapping_get(root, "x").is_some());
652    }
653
654    #[test]
655    fn uncompressed_data_is_borrowed_not_copied() {
656        let file = build(b"borrow me", Compression::None, None);
657        let r = Reader::from_bytes(file).unwrap();
658        assert!(matches!(r.block_data(0).unwrap(), Cow::Borrowed(_)));
659    }
660
661    #[test]
662    fn compressed_data_round_trips() {
663        let payload = vec![7u8; 4096];
664        for c in crate::compression::available() {
665            let file = build(&payload, c, None);
666            let r = Reader::from_bytes(file).unwrap();
667            assert_eq!(r.block_compression(0).unwrap(), c);
668            assert_eq!(&*r.block_data(0).unwrap(), &payload[..], "{c:?}");
669            // The raw form is the compressed bytes.
670            assert!(r.block_raw(0).unwrap().len() < payload.len(), "{c:?}");
671        }
672    }
673
674    #[test]
675    fn valid_checksums_verify() {
676        let payload = b"checksum me".to_vec();
677        let file = build(&payload, Compression::None, Some(&payload));
678        let r = Reader::from_bytes(file).unwrap();
679        let (status, _) = r.verify_block_checksum(0).unwrap();
680        assert_eq!(status, ChecksumStatus::Valid);
681    }
682
683    #[test]
684    fn invalid_checksums_are_reported() {
685        let payload = b"checksum me".to_vec();
686        let file = build(&payload, Compression::None, Some(b"something else"));
687        let r = Reader::from_bytes(file).unwrap();
688        let (status, computed) = r.verify_block_checksum(0).unwrap();
689        assert_eq!(status, ChecksumStatus::Invalid);
690        assert_eq!(computed, md5_of(&payload), "the digest of the real data is reported");
691    }
692
693    #[test]
694    fn an_absent_checksum_is_not_a_failure() {
695        let file = build(b"no checksum", Compression::None, None);
696        let r = Reader::from_bytes(file).unwrap();
697        let (status, _) = r.verify_block_checksum(0).unwrap();
698        assert_eq!(status, ChecksumStatus::Absent);
699        assert!(!status.is_failure());
700    }
701
702    #[cfg(feature = "zlib")]
703    #[test]
704    fn compressed_checksums_cover_the_stored_bytes() {
705        // What the specification means: the digest is over the data as stored.
706        let payload = vec![3u8; 2048];
707        let stored = Compression::Zlib.compress(&payload).unwrap();
708        let file = build(&payload, Compression::Zlib, Some(&stored));
709        let r = Reader::from_bytes(file).unwrap();
710        assert_eq!(r.verify_block_checksum(0).unwrap().0, ChecksumStatus::Valid);
711    }
712
713    /// Python asdf 5.x and earlier checksum the *uncompressed* data for a
714    /// compressed block. libasdf detects those writers from `asdf_library`
715    /// and verifies against the decompressed bytes instead; so do we.
716    #[cfg(feature = "zlib")]
717    #[test]
718    fn the_python_checksum_bug_is_worked_around() {
719        let payload = vec![9u8; 2048];
720        let stored = Compression::Zlib.compress(&payload).unwrap();
721
722        let make = |library_version: &str| {
723            let mut buf = Vec::new();
724            buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
725            buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
726            buf.extend_from_slice(
727                format!(
728                    "asdf_library: !core/software-1.0.0 {{name: asdf, version: {library_version}}}\n"
729                )
730                .as_bytes(),
731            );
732            buf.extend_from_slice(b"...\n");
733
734            let mut header = BlockHeader {
735                allocated_size: stored.len() as u64,
736                used_size: stored.len() as u64,
737                data_size: payload.len() as u64,
738                // The bug: digest taken over the *uncompressed* payload.
739                checksum: md5_of(&payload),
740                ..Default::default()
741            };
742            header.set_compression("zlib").unwrap();
743            header.write(&mut buf);
744            buf.extend_from_slice(&stored);
745            buf
746        };
747
748        // A writer known to be affected: accepted via the workaround.
749        let r = Reader::from_bytes(make("4.1.0")).unwrap();
750        assert!(r.has_python_checksum_bug());
751        assert_eq!(
752            r.verify_block_checksum(0).unwrap().0,
753            ChecksumStatus::Valid,
754            "an affected writer's checksum should verify against the uncompressed data"
755        );
756
757        // A writer past the fix: the same file is genuinely invalid.
758        let r = Reader::from_bytes(make("6.0.0")).unwrap();
759        assert!(!r.has_python_checksum_bug());
760        assert_eq!(
761            r.verify_block_checksum(0).unwrap().0,
762            ChecksumStatus::Invalid,
763            "the workaround must not apply to writers that are not affected"
764        );
765    }
766
767    #[test]
768    fn out_of_range_block_indices_error() {
769        let file = build(b"one block", Compression::None, None);
770        let r = Reader::from_bytes(file).unwrap();
771        assert!(r.block(1).is_err());
772        assert!(r.block_data(99).is_err());
773    }
774
775    #[test]
776    fn a_file_without_a_tree_reads_cleanly() {
777        let mut buf = Vec::new();
778        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
779        let header =
780            BlockHeader { allocated_size: 4, used_size: 4, data_size: 4, ..Default::default() };
781        header.write(&mut buf);
782        buf.extend_from_slice(b"data");
783
784        let r = Reader::from_bytes(buf).unwrap();
785        assert!(r.tree().unwrap().is_none());
786        assert_eq!(&*r.block_data(0).unwrap(), b"data");
787    }
788}