Skip to main content

asdf_core/
reader.rs

1//! Reading an ASDF file: its tree, and the data in its binary blocks.
2
3use std::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 std::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 std::fmt::Debug for Source {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::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        let referenced = Reader::open(&target).map_err(|e| {
409            err!(InvalidArgument, "external source {uri:?} ({}): {e}", target.display())
410        })?;
411        if referenced.block_count() == 0 {
412            return Err(err!(InvalidArgument, "external source {uri:?} has no blocks"));
413        }
414        Ok(referenced.block_data(0)?.into_owned())
415    }
416
417    /// Parse the tree with every block-backed `core/ndarray` replaced by its
418    /// data inline.
419    ///
420    /// This is the transformation the ASDF Standard's reference corpus asks
421    /// for before comparing a file against its expected YAML. An array whose
422    /// data lives in another file -- exploded form's external `source` -- is
423    /// resolved through [`Reader::external_block`], which needs this file to
424    /// have been read from disk.
425    ///
426    /// Returns the transformed tree and the paths of any arrays that could
427    /// not be inlined.
428    pub fn tree_inlined(&self) -> Result<Option<(Document, Vec<String>)>> {
429        use crate::core::elements::{decode_all, inline_ndarray};
430        use crate::core::ndarray::Ndarray;
431
432        let Some(mut doc) = self.tree()? else { return Ok(None) };
433        let mut skipped = Vec::new();
434
435        for id in find_tagged(&doc, "core/ndarray") {
436            let nd = match Ndarray::parse(&doc, id) {
437                Ok(nd) => nd,
438                Err(e) => {
439                    skipped.push(format!("{id:?}: {e}"));
440                    continue;
441                }
442            };
443
444            // Inline data is already where it needs to be.
445            if matches!(nd.source, crate::core::ndarray::Source::Inline(_)) {
446                continue;
447            }
448
449            // An external source names another file; its first block holds
450            // the data, which is how exploded form is written.
451            let data = if let crate::core::ndarray::Source::External(uri) = &nd.source {
452                match self.external_block(uri) {
453                    Ok(bytes) => Cow::Owned(bytes),
454                    Err(e) => {
455                        skipped.push(format!("{id:?}: {e}"));
456                        continue;
457                    }
458                }
459            } else {
460                let Some(index) = self.block_index_for(&nd.source) else {
461                    skipped.push(format!("{id:?}: data is outside this file ({:?})", nd.source));
462                    continue;
463                };
464                match self.block_data(index) {
465                    Ok(d) => d,
466                    Err(e) => {
467                        skipped.push(format!("{id:?}: block {index}: {e}"));
468                        continue;
469                    }
470                }
471            };
472
473            let shape = match nd.resolved_shape(Some(data.len() as u64)) {
474                Ok(s) => s,
475                Err(e) => {
476                    skipped.push(format!("{id:?}: {e}"));
477                    continue;
478                }
479            };
480
481            match decode_all(&nd, &shape, &data) {
482                Ok(elements) => inline_ndarray(&mut doc, id, &elements, &shape)?,
483                Err(e) => skipped.push(format!("{id:?}: {e}")),
484            }
485        }
486        Ok(Some((doc, skipped)))
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::block::header::BlockHeader;
494    use crate::layout::write_block_index;
495
496    /// Build a file with one block, optionally compressed and checksummed.
497    fn build(payload: &[u8], compression: Compression, checksum_over: Option<&[u8]>) -> Vec<u8> {
498        let stored = compression.compress(payload).unwrap();
499        let mut buf = Vec::new();
500        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
501        buf.extend_from_slice(
502            b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\nx: 1\n...\n",
503        );
504
505        let mut header = BlockHeader {
506            allocated_size: stored.len() as u64,
507            used_size: stored.len() as u64,
508            data_size: payload.len() as u64,
509            ..Default::default()
510        };
511        header.set_compression(compression.name()).unwrap();
512        if let Some(over) = checksum_over {
513            header.checksum = md5_of(over);
514        }
515
516        let offset = buf.len() as u64;
517        header.write(&mut buf);
518        buf.extend_from_slice(&stored);
519        buf.extend_from_slice(&write_block_index(&[offset]));
520        buf
521    }
522
523    #[test]
524    fn external_source_uris_may_not_escape_the_directory() {
525        // The only shape the standard's exploded form uses.
526        assert!(external_relative_path("exploded0000.asdf").is_ok());
527        assert!(external_relative_path("data/block0.asdf").is_ok());
528        assert!(external_relative_path("./here.asdf").is_ok());
529
530        // Everything that could reach outside the referring file's tree.
531        for bad in [
532            "",
533            "/etc/passwd",
534            "../secrets.asdf",
535            "data/../../secrets.asdf",
536            "file:///etc/passwd",
537            "https://example.invalid/x.asdf",
538        ] {
539            assert!(
540                external_relative_path(bad).is_err(),
541                "{bad:?} should be rejected as an external source"
542            );
543        }
544    }
545
546    #[test]
547    fn a_memory_backed_file_resolves_no_external_sources() {
548        let file = build(b"whatever", Compression::None, None);
549        let r = Reader::from_bytes(file);
550        let r = r.unwrap();
551        assert!(r.path().is_none());
552        // There is no directory to resolve against, so this must fail rather
553        // than guess at the working directory.
554        assert!(r.external_block("other.asdf").is_err());
555    }
556
557    #[test]
558    fn an_external_source_is_read_from_the_neighbouring_file() {
559        let dir = std::env::temp_dir().join(format!("asdf-exploded-{}", std::process::id()));
560        std::fs::create_dir_all(&dir).unwrap();
561
562        // The data file: one block holding four little-endian int32s.
563        let payload: Vec<u8> = [1i32, 2, 3, 4].iter().flat_map(|v| v.to_le_bytes()).collect();
564        let data_file = dir.join("holder0000.asdf");
565        std::fs::write(&data_file, build(&payload, Compression::None, None)).unwrap();
566
567        // The referring file: a tree naming it, and no blocks of its own.
568        let mut buf = Vec::new();
569        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
570        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
571        buf.extend_from_slice(
572            b"data: !core/ndarray-1.1.0\n  source: holder0000.asdf\n  \
573              datatype: int32\n  byteorder: little\n  shape: [4]\n",
574        );
575        buf.extend_from_slice(b"...\n");
576        let referring = dir.join("holder.asdf");
577        std::fs::write(&referring, buf).unwrap();
578
579        let r = Reader::open(&referring).unwrap();
580        assert_eq!(r.block_count(), 0, "the referring file holds no blocks itself");
581        assert_eq!(r.external_block("holder0000.asdf").unwrap(), payload);
582
583        // And inlining follows the reference through to real values.
584        let (doc, skipped) = r.tree_inlined().unwrap().unwrap();
585        assert!(skipped.is_empty(), "nothing should be left un-inlined: {skipped:?}");
586        let root = doc.root().unwrap();
587        let array = doc.mapping_get(root, "data").unwrap();
588        let values = doc.mapping_get(array, "data").unwrap();
589        let items = doc.sequence_items(values).unwrap();
590        let read: Vec<&str> = items.iter().map(|i| doc.resolved(*i).as_str().unwrap()).collect();
591        assert_eq!(read, ["1", "2", "3", "4"]);
592        // `source` is replaced, as it is for an internal block.
593        assert!(doc.mapping_get(array, "source").is_none());
594
595        std::fs::remove_dir_all(&dir).ok();
596    }
597
598    #[test]
599    fn a_missing_external_file_is_reported_not_silently_skipped() {
600        let dir =
601            std::env::temp_dir().join(format!("asdf-exploded-missing-{}", std::process::id()));
602        std::fs::create_dir_all(&dir).unwrap();
603        let referring = dir.join("dangling.asdf");
604        let mut buf = Vec::new();
605        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
606        buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
607        buf.extend_from_slice(
608            b"data: !core/ndarray-1.1.0\n  source: nowhere0000.asdf\n  \
609              datatype: int32\n  byteorder: little\n  shape: [4]\n",
610        );
611        buf.extend_from_slice(b"...\n");
612        std::fs::write(&referring, buf).unwrap();
613
614        let r = Reader::open(&referring).unwrap();
615        let (_, skipped) = r.tree_inlined().unwrap().unwrap();
616        assert_eq!(skipped.len(), 1);
617        assert!(skipped[0].contains("nowhere0000.asdf"), "{skipped:?}");
618
619        std::fs::remove_dir_all(&dir).ok();
620    }
621
622    #[test]
623    fn reads_tree_and_block_data() {
624        let payload = b"hello block data".to_vec();
625        let file = build(&payload, Compression::None, None);
626        let r = Reader::from_bytes(file).unwrap();
627
628        assert_eq!(r.block_count(), 1);
629        assert_eq!(&*r.block_data(0).unwrap(), &payload[..]);
630
631        let doc = r.tree().unwrap().unwrap();
632        let root = doc.root().unwrap();
633        assert!(doc.mapping_get(root, "x").is_some());
634    }
635
636    #[test]
637    fn uncompressed_data_is_borrowed_not_copied() {
638        let file = build(b"borrow me", Compression::None, None);
639        let r = Reader::from_bytes(file).unwrap();
640        assert!(matches!(r.block_data(0).unwrap(), Cow::Borrowed(_)));
641    }
642
643    #[test]
644    fn compressed_data_round_trips() {
645        let payload = vec![7u8; 4096];
646        for c in crate::compression::available() {
647            let file = build(&payload, c, None);
648            let r = Reader::from_bytes(file).unwrap();
649            assert_eq!(r.block_compression(0).unwrap(), c);
650            assert_eq!(&*r.block_data(0).unwrap(), &payload[..], "{c:?}");
651            // The raw form is the compressed bytes.
652            assert!(r.block_raw(0).unwrap().len() < payload.len(), "{c:?}");
653        }
654    }
655
656    #[test]
657    fn valid_checksums_verify() {
658        let payload = b"checksum me".to_vec();
659        let file = build(&payload, Compression::None, Some(&payload));
660        let r = Reader::from_bytes(file).unwrap();
661        let (status, _) = r.verify_block_checksum(0).unwrap();
662        assert_eq!(status, ChecksumStatus::Valid);
663    }
664
665    #[test]
666    fn invalid_checksums_are_reported() {
667        let payload = b"checksum me".to_vec();
668        let file = build(&payload, Compression::None, Some(b"something else"));
669        let r = Reader::from_bytes(file).unwrap();
670        let (status, computed) = r.verify_block_checksum(0).unwrap();
671        assert_eq!(status, ChecksumStatus::Invalid);
672        assert_eq!(computed, md5_of(&payload), "the digest of the real data is reported");
673    }
674
675    #[test]
676    fn an_absent_checksum_is_not_a_failure() {
677        let file = build(b"no checksum", Compression::None, None);
678        let r = Reader::from_bytes(file).unwrap();
679        let (status, _) = r.verify_block_checksum(0).unwrap();
680        assert_eq!(status, ChecksumStatus::Absent);
681        assert!(!status.is_failure());
682    }
683
684    #[cfg(feature = "zlib")]
685    #[test]
686    fn compressed_checksums_cover_the_stored_bytes() {
687        // What the specification means: the digest is over the data as stored.
688        let payload = vec![3u8; 2048];
689        let stored = Compression::Zlib.compress(&payload).unwrap();
690        let file = build(&payload, Compression::Zlib, Some(&stored));
691        let r = Reader::from_bytes(file).unwrap();
692        assert_eq!(r.verify_block_checksum(0).unwrap().0, ChecksumStatus::Valid);
693    }
694
695    /// Python asdf 5.x and earlier checksum the *uncompressed* data for a
696    /// compressed block. libasdf detects those writers from `asdf_library`
697    /// and verifies against the decompressed bytes instead; so do we.
698    #[cfg(feature = "zlib")]
699    #[test]
700    fn the_python_checksum_bug_is_worked_around() {
701        let payload = vec![9u8; 2048];
702        let stored = Compression::Zlib.compress(&payload).unwrap();
703
704        let make = |library_version: &str| {
705            let mut buf = Vec::new();
706            buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
707            buf.extend_from_slice(b"%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\n");
708            buf.extend_from_slice(
709                format!(
710                    "asdf_library: !core/software-1.0.0 {{name: asdf, version: {library_version}}}\n"
711                )
712                .as_bytes(),
713            );
714            buf.extend_from_slice(b"...\n");
715
716            let mut header = BlockHeader {
717                allocated_size: stored.len() as u64,
718                used_size: stored.len() as u64,
719                data_size: payload.len() as u64,
720                // The bug: digest taken over the *uncompressed* payload.
721                checksum: md5_of(&payload),
722                ..Default::default()
723            };
724            header.set_compression("zlib").unwrap();
725            header.write(&mut buf);
726            buf.extend_from_slice(&stored);
727            buf
728        };
729
730        // A writer known to be affected: accepted via the workaround.
731        let r = Reader::from_bytes(make("4.1.0")).unwrap();
732        assert!(r.has_python_checksum_bug());
733        assert_eq!(
734            r.verify_block_checksum(0).unwrap().0,
735            ChecksumStatus::Valid,
736            "an affected writer's checksum should verify against the uncompressed data"
737        );
738
739        // A writer past the fix: the same file is genuinely invalid.
740        let r = Reader::from_bytes(make("6.0.0")).unwrap();
741        assert!(!r.has_python_checksum_bug());
742        assert_eq!(
743            r.verify_block_checksum(0).unwrap().0,
744            ChecksumStatus::Invalid,
745            "the workaround must not apply to writers that are not affected"
746        );
747    }
748
749    #[test]
750    fn out_of_range_block_indices_error() {
751        let file = build(b"one block", Compression::None, None);
752        let r = Reader::from_bytes(file).unwrap();
753        assert!(r.block(1).is_err());
754        assert!(r.block_data(99).is_err());
755    }
756
757    #[test]
758    fn a_file_without_a_tree_reads_cleanly() {
759        let mut buf = Vec::new();
760        buf.extend_from_slice(b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
761        let header =
762            BlockHeader { allocated_size: 4, used_size: 4, data_size: 4, ..Default::default() };
763        header.write(&mut buf);
764        buf.extend_from_slice(b"data");
765
766        let r = Reader::from_bytes(buf).unwrap();
767        assert!(r.tree().unwrap().is_none());
768        assert_eq!(&*r.block_data(0).unwrap(), b"data");
769    }
770}