1use 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
13enum Source {
15 #[cfg(not(miri))]
22 Mapped(memmap2::Mmap),
23 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum ChecksumStatus {
52 Absent,
55 Valid,
57 Invalid,
59}
60
61impl ChecksumStatus {
62 pub fn is_failure(self) -> bool {
66 self == ChecksumStatus::Invalid
67 }
68}
69
70fn 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 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#[derive(Debug)]
112pub struct Reader {
113 source: Source,
114 layout: Layout,
115 path: Option<PathBuf>,
120}
121
122impl Reader {
123 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
127 let path = path.as_ref();
128
129 #[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 #[cfg(not(miri))]
154 fn map(file: std::fs::File, path: &Path) -> Result<Self> {
155 #[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 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 pub fn path(&self) -> Option<&Path> {
177 self.path.as_deref()
178 }
179
180 pub fn bytes(&self) -> &[u8] {
182 &self.source
183 }
184
185 pub fn layout(&self) -> &Layout {
187 &self.layout
188 }
189
190 pub fn tree_text(&self) -> Option<&str> {
192 self.layout.tree_str(&self.source)
193 }
194
195 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 pub fn block_count(&self) -> usize {
207 self.layout.blocks.len()
208 }
209
210 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 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 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 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 pub fn block_compression(&self, index: usize) -> Result<Compression> {
251 Compression::from_name(self.block(index)?.header.compression_name())
252 }
253
254 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 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 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 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
337fn 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
345fn 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 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 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 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 if matches!(nd.source, crate::core::ndarray::Source::Inline(_)) {
446 continue;
447 }
448
449 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}