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