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 Mapped(memmap2::Mmap),
18 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
44pub enum ChecksumStatus {
45 Absent,
48 Valid,
50 Invalid,
52}
53
54impl ChecksumStatus {
55 pub fn is_failure(self) -> bool {
59 self == ChecksumStatus::Invalid
60 }
61}
62
63fn 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 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#[derive(Debug)]
105pub struct Reader {
106 source: Source,
107 layout: Layout,
108 path: Option<PathBuf>,
113}
114
115impl Reader {
116 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
120 let path = path.as_ref();
121
122 #[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 #[cfg(not(miri))]
147 fn map(file: std::fs::File, path: &Path) -> Result<Self> {
148 #[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 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 pub fn path(&self) -> Option<&Path> {
170 self.path.as_deref()
171 }
172
173 pub fn bytes(&self) -> &[u8] {
175 &self.source
176 }
177
178 pub fn layout(&self) -> &Layout {
180 &self.layout
181 }
182
183 pub fn tree_text(&self) -> Option<&str> {
185 self.layout.tree_str(&self.source)
186 }
187
188 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 pub fn block_count(&self) -> usize {
200 self.layout.blocks.len()
201 }
202
203 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 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 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 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 pub fn block_compression(&self, index: usize) -> Result<Compression> {
244 Compression::from_name(self.block(index)?.header.compression_name())
245 }
246
247 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 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 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 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
330fn 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
338fn 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 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 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 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 if matches!(nd.source, crate::core::ndarray::Source::Inline(_)) {
439 continue;
440 }
441
442 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}