1use 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
13enum Source {
15 #[cfg(not(miri))]
22 Mapped(memmap2::Mmap),
23 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#[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 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 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 if matches!(nd.source, crate::core::ndarray::Source::Inline(_)) {
464 continue;
465 }
466
467 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}