Skip to main content

assetpack_core/
file_reader.rs

1use std::{
2  collections::BTreeSet,
3  io::{self, BufReader, Cursor, Write},
4};
5
6use sha3::{Digest, Sha3_256};
7
8use crate::{
9  AsyncObjectSource, Error, Hash32, ObjectKind, RecipeData, Result, TransformDecoderRegistry, parse_recipe, recipe::preflight_recipe_limits,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct FileReadLimits {
14  pub max_chunk_count: u64,
15  pub max_stored_stream_bytes: u64,
16  pub max_original_file_bytes: u64,
17}
18
19impl Default for FileReadLimits {
20  fn default() -> Self {
21    Self {
22      max_chunk_count: 1_000_000,
23      max_stored_stream_bytes: 256 * 1024 * 1024,
24      max_original_file_bytes: 256 * 1024 * 1024,
25    }
26  }
27}
28
29pub struct FileReader<'a, S: ?Sized> {
30  source: &'a S,
31  decoders: &'a TransformDecoderRegistry,
32  limits: FileReadLimits,
33}
34
35impl<'a, S: crate::ObjectSource + ?Sized> FileReader<'a, S> {
36  pub fn new(source: &'a S, decoders: &'a TransformDecoderRegistry, limits: FileReadLimits) -> Self {
37    Self { source, decoders, limits }
38  }
39
40  pub fn resolve_closure(&self, recipe_hash: Hash32) -> Result<BTreeSet<Hash32>> {
41    let recipe = self.load_recipe(recipe_hash)?;
42    let mut hashes = BTreeSet::from([recipe_hash]);
43    for (hash, expected_len) in &recipe.chunks {
44      let object = self.source.read_object(hash)?.ok_or(Error::ObjectNotFound)?;
45      validate_chunk(&object.hash, object.kind, object.bytes.len() as u64, u64::from(*expected_len))?;
46      hashes.insert(*hash);
47    }
48    Ok(hashes)
49  }
50
51  pub fn read_stored_stream(&self, recipe_hash: Hash32) -> Result<Vec<u8>> {
52    let recipe = self.load_recipe(recipe_hash)?;
53    self.read_stored_for_recipe(&recipe)
54  }
55
56  pub fn restore_file(&self, recipe_hash: Hash32, output: &mut dyn Write) -> Result<()> {
57    let recipe = self.load_recipe(recipe_hash)?;
58    let stored = self.read_stored_for_recipe(&recipe)?;
59    decode_recipe(self.decoders, &recipe, stored, output)
60  }
61
62  pub fn read_file(&self, recipe_hash: Hash32) -> Result<Vec<u8>> {
63    let recipe = self.load_recipe(recipe_hash)?;
64    let stored = self.read_stored_for_recipe(&recipe)?;
65    read_decoded_file(self.decoders, &recipe, stored)
66  }
67
68  fn load_recipe(&self, recipe_hash: Hash32) -> Result<RecipeData> {
69    let object = self.source.read_object(&recipe_hash)?.ok_or(Error::ObjectNotFound)?;
70    load_recipe_from_object(object, recipe_hash, self.limits)
71  }
72
73  fn read_stored_for_recipe(&self, recipe: &RecipeData) -> Result<Vec<u8>> {
74    let mut stored = Vec::new();
75    for (hash, expected_len) in &recipe.chunks {
76      let object = self.source.read_object(hash)?.ok_or(Error::ObjectNotFound)?;
77      validate_chunk(&object.hash, object.kind, object.bytes.len() as u64, u64::from(*expected_len))?;
78      append_chunk(&mut stored, &object.bytes, recipe.stored_stream_size)?;
79    }
80    Ok(stored)
81  }
82}
83
84pub struct AsyncFileReader<'a, S: ?Sized> {
85  source: &'a S,
86  decoders: &'a TransformDecoderRegistry,
87  limits: FileReadLimits,
88}
89
90impl<'a, S: AsyncObjectSource + ?Sized> AsyncFileReader<'a, S> {
91  pub fn new(source: &'a S, decoders: &'a TransformDecoderRegistry, limits: FileReadLimits) -> Self {
92    Self { source, decoders, limits }
93  }
94
95  pub async fn resolve_closure(&self, recipe_hash: Hash32) -> Result<BTreeSet<Hash32>> {
96    let recipe = self.load_recipe(recipe_hash).await?;
97    let mut hashes = BTreeSet::from([recipe_hash]);
98    for (hash, expected_len) in &recipe.chunks {
99      let object = self.source.read_object(hash).await?.ok_or(Error::ObjectNotFound)?;
100      validate_chunk(&object.hash, object.kind, object.bytes.len() as u64, u64::from(*expected_len))?;
101      hashes.insert(*hash);
102    }
103    Ok(hashes)
104  }
105
106  pub async fn read_stored_stream(&self, recipe_hash: Hash32) -> Result<Vec<u8>> {
107    let recipe = self.load_recipe(recipe_hash).await?;
108    self.read_stored_for_recipe(&recipe).await
109  }
110
111  pub async fn restore_file(&self, recipe_hash: Hash32, output: &mut dyn Write) -> Result<()> {
112    let recipe = self.load_recipe(recipe_hash).await?;
113    let stored = self.read_stored_for_recipe(&recipe).await?;
114    decode_recipe(self.decoders, &recipe, stored, output)
115  }
116
117  pub async fn read_file(&self, recipe_hash: Hash32) -> Result<Vec<u8>> {
118    let recipe = self.load_recipe(recipe_hash).await?;
119    let stored = self.read_stored_for_recipe(&recipe).await?;
120    read_decoded_file(self.decoders, &recipe, stored)
121  }
122
123  async fn load_recipe(&self, recipe_hash: Hash32) -> Result<RecipeData> {
124    let object = self.source.read_object(&recipe_hash).await?.ok_or(Error::ObjectNotFound)?;
125    load_recipe_from_object(object, recipe_hash, self.limits)
126  }
127
128  async fn read_stored_for_recipe(&self, recipe: &RecipeData) -> Result<Vec<u8>> {
129    let mut stored = Vec::new();
130    for (hash, expected_len) in &recipe.chunks {
131      let object = self.source.read_object(hash).await?.ok_or(Error::ObjectNotFound)?;
132      validate_chunk(&object.hash, object.kind, object.bytes.len() as u64, u64::from(*expected_len))?;
133      append_chunk(&mut stored, &object.bytes, recipe.stored_stream_size)?;
134    }
135    Ok(stored)
136  }
137}
138
139fn load_recipe_from_object(object: crate::VerifiedObject, recipe_hash: Hash32, limits: FileReadLimits) -> Result<RecipeData> {
140  if object.kind != ObjectKind::Recipe {
141    return Err(Error::ObjectKindMismatch {
142      hash: recipe_hash,
143      expected: ObjectKind::Recipe,
144      actual: object.kind,
145    });
146  }
147  let (original_file_size, chunk_count) =
148    preflight_recipe_limits(&object.bytes).map_err(|error| Error::InvalidRecipe(error.to_string()))?;
149  check_limit("chunk count", limits.max_chunk_count, u64::from(chunk_count))?;
150  check_limit("original file bytes", limits.max_original_file_bytes, original_file_size)?;
151  let recipe = parse_recipe(&object.bytes).map_err(|error| Error::InvalidRecipe(error.to_string()))?;
152  check_limit("stored stream bytes", limits.max_stored_stream_bytes, recipe.stored_stream_size)?;
153  Ok(recipe)
154}
155
156fn append_chunk(stored: &mut Vec<u8>, bytes: &[u8], expected_total: u64) -> Result<()> {
157  let requested = stored
158    .len()
159    .checked_add(bytes.len())
160    .and_then(|length| u64::try_from(length).ok())
161    .ok_or(Error::FileReadAllocationFailed {
162      buffer: "stored stream",
163      requested: expected_total,
164    })?;
165  stored.try_reserve(bytes.len()).map_err(|_| Error::FileReadAllocationFailed {
166    buffer: "stored stream",
167    requested,
168  })?;
169  stored.extend_from_slice(bytes);
170  Ok(())
171}
172
173fn validate_chunk(hash: &Hash32, kind: ObjectKind, actual_len: u64, expected_len: u64) -> Result<()> {
174  if kind != ObjectKind::Chunk {
175    return Err(Error::ObjectKindMismatch {
176      hash: *hash,
177      expected: ObjectKind::Chunk,
178      actual: kind,
179    });
180  }
181  if actual_len != expected_len {
182    return Err(Error::RecipeChunkLengthMismatch {
183      hash: *hash,
184      expected: expected_len,
185      actual: actual_len,
186    });
187  }
188  Ok(())
189}
190
191fn check_limit(limit: &'static str, maximum: u64, actual: u64) -> Result<()> {
192  if actual > maximum {
193    return Err(Error::FileReadLimitExceeded { limit, maximum, actual });
194  }
195  Ok(())
196}
197
198fn read_decoded_file(decoders: &TransformDecoderRegistry, recipe: &RecipeData, stored: Vec<u8>) -> Result<Vec<u8>> {
199  let mut output = FallibleVecWriter::default();
200  let result = decode_recipe(decoders, recipe, stored, &mut output);
201  if let Some(requested) = output.allocation_failure {
202    return Err(Error::FileReadAllocationFailed {
203      buffer: "original file",
204      requested,
205    });
206  }
207  result?;
208  Ok(output.bytes)
209}
210
211fn decode_recipe(decoders: &TransformDecoderRegistry, recipe: &RecipeData, stored: Vec<u8>, output: &mut dyn Write) -> Result<()> {
212  let decoder = decoders.get(recipe.transform_id, recipe.transform_version)?;
213  let mut input = BufReader::new(Cursor::new(stored));
214  let mut verified = VerifyingWriter::new(output, recipe.original_file_size);
215  let result = decoder.decode(&mut input, &mut verified);
216  if verified.exceeded {
217    return Err(original_mismatch(recipe, &verified));
218  }
219  result?;
220  let actual_hash = verified.hash();
221  if verified.written != recipe.original_file_size || actual_hash != recipe.original_file_hash {
222    return Err(Error::OriginalFileMismatch {
223      expected_size: recipe.original_file_size,
224      actual_size: verified.written,
225      expected_hash: recipe.original_file_hash,
226      actual_hash,
227    });
228  }
229  Ok(())
230}
231
232fn original_mismatch(recipe: &RecipeData, writer: &VerifyingWriter<'_>) -> Error {
233  Error::OriginalFileMismatch {
234    expected_size: recipe.original_file_size,
235    actual_size: writer.written,
236    expected_hash: recipe.original_file_hash,
237    actual_hash: writer.hash(),
238  }
239}
240
241#[derive(Default)]
242struct FallibleVecWriter {
243  bytes: Vec<u8>,
244  allocation_failure: Option<u64>,
245}
246
247impl Write for FallibleVecWriter {
248  fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
249    let requested = self
250      .bytes
251      .len()
252      .checked_add(bytes.len())
253      .and_then(|length| u64::try_from(length).ok())
254      .ok_or_else(|| io::Error::new(io::ErrorKind::OutOfMemory, "original file length exceeds address space"))?;
255    if self.bytes.try_reserve(bytes.len()).is_err() {
256      self.allocation_failure = Some(requested);
257      return Err(io::Error::new(
258        io::ErrorKind::OutOfMemory,
259        "unable to allocate original file buffer",
260      ));
261    }
262    self.bytes.extend_from_slice(bytes);
263    Ok(bytes.len())
264  }
265
266  fn flush(&mut self) -> io::Result<()> {
267    Ok(())
268  }
269}
270
271struct VerifyingWriter<'a> {
272  output: &'a mut dyn Write,
273  hasher: Sha3_256,
274  maximum: u64,
275  written: u64,
276  exceeded: bool,
277}
278
279impl<'a> VerifyingWriter<'a> {
280  fn new(output: &'a mut dyn Write, maximum: u64) -> Self {
281    Self {
282      output,
283      hasher: Sha3_256::new(),
284      maximum,
285      written: 0,
286      exceeded: false,
287    }
288  }
289
290  fn hash(&self) -> Hash32 {
291    Hash32::new(self.hasher.clone().finalize().into())
292  }
293}
294
295impl Write for VerifyingWriter<'_> {
296  fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
297    let next = self.written.checked_add(bytes.len() as u64).ok_or_else(|| {
298      self.exceeded = true;
299      io::Error::new(io::ErrorKind::FileTooLarge, "decoded file length overflow")
300    })?;
301    if next > self.maximum {
302      self.exceeded = true;
303      return Err(io::Error::new(io::ErrorKind::FileTooLarge, "decoded file exceeds declared size"));
304    }
305    let written = self.output.write(bytes)?;
306    self.hasher.update(&bytes[..written]);
307    self.written += written as u64;
308    Ok(written)
309  }
310
311  fn flush(&mut self) -> io::Result<()> {
312    self.output.flush()
313  }
314}
315
316#[cfg(test)]
317mod tests {
318  use std::{
319    collections::BTreeMap,
320    sync::{
321      Arc,
322      atomic::{AtomicUsize, Ordering},
323    },
324  };
325
326  use super::*;
327  use crate::{TRANSFORM_ID_NONE, TRANSFORM_VERSION_NONE, VerifiedObject, build_recipe};
328
329  #[derive(Default)]
330  struct MemorySource(BTreeMap<Hash32, VerifiedObject>);
331
332  impl AsyncObjectSource for MemorySource {
333    async fn read_object(&self, hash: &Hash32) -> Result<Option<VerifiedObject>> {
334      Ok(self.0.get(hash).cloned())
335    }
336  }
337
338  fn source_for(
339    chunks: &[&[u8]],
340    order: &[usize],
341    original_size: u64,
342    original_hash: Hash32,
343    transform: (u16, u16),
344  ) -> (MemorySource, Hash32) {
345    let mut source = MemorySource::default();
346    let hashes = chunks
347      .iter()
348      .map(|bytes| {
349        let hash = Hash32::sha3_256(bytes);
350        source.0.insert(
351          hash,
352          VerifiedObject {
353            hash,
354            kind: ObjectKind::Chunk,
355            bytes: bytes.to_vec(),
356          },
357        );
358        hash
359      })
360      .collect::<Vec<_>>();
361    let recipe_chunks = order
362      .iter()
363      .map(|index| (hashes[*index], chunks[*index].len() as u32))
364      .collect::<Vec<_>>();
365    let recipe = build_recipe(original_size, &recipe_chunks, original_hash, transform.0, transform.1);
366    let recipe_hash = Hash32::sha3_256(&recipe);
367    source.0.insert(
368      recipe_hash,
369      VerifiedObject {
370        hash: recipe_hash,
371        kind: ObjectKind::Recipe,
372        bytes: recipe,
373      },
374    );
375    (source, recipe_hash)
376  }
377
378  #[tokio::test]
379  async fn none_reader_handles_empty_single_multi_and_duplicate_chunks() {
380    for (chunks, order) in [
381      (vec![], vec![]),
382      (vec![b"alpha".as_slice()], vec![0]),
383      (vec![b"alpha".as_slice(), b"beta".as_slice()], vec![0, 1]),
384      (vec![b"alpha".as_slice(), b"beta".as_slice()], vec![0, 1, 0]),
385    ] {
386      let expected = order.iter().flat_map(|index| chunks[*index]).copied().collect::<Vec<_>>();
387      let (source, recipe_hash) = source_for(
388        &chunks,
389        &order,
390        expected.len() as u64,
391        Hash32::sha3_256(&expected),
392        (TRANSFORM_ID_NONE, TRANSFORM_VERSION_NONE),
393      );
394      let registry = TransformDecoderRegistry::default();
395      let reader = AsyncFileReader::new(&source, &registry, FileReadLimits::default());
396      assert_eq!(reader.read_stored_stream(recipe_hash).await.unwrap(), expected);
397      assert_eq!(reader.read_file(recipe_hash).await.unwrap(), expected);
398      let mut restored = Vec::new();
399      reader.restore_file(recipe_hash, &mut restored).await.unwrap();
400      assert_eq!(restored, expected);
401      assert_eq!(reader.resolve_closure(recipe_hash).await.unwrap().len(), chunks.len() + 1);
402    }
403  }
404
405  #[tokio::test]
406  async fn errors_are_classified_by_file_reader_boundary() {
407    #[derive(Clone, Copy)]
408    enum ExpectedError {
409      NotFound,
410      KindMismatch,
411      InvalidRecipe,
412      ChunkLengthMismatch,
413    }
414
415    let expected = b"alpha";
416    type ErrorCase = (&'static str, fn(&mut MemorySource, Hash32, Hash32), ExpectedError);
417    let cases: [ErrorCase; 6] = [
418      (
419        "missing recipe",
420        |source, recipe_hash, _| {
421          source.0.remove(&recipe_hash);
422        },
423        ExpectedError::NotFound,
424      ),
425      (
426        "missing chunk",
427        |source, _, chunk_hash| {
428          source.0.remove(&chunk_hash);
429        },
430        ExpectedError::NotFound,
431      ),
432      (
433        "recipe kind is chunk",
434        |source, recipe_hash, _| {
435          source.0.get_mut(&recipe_hash).unwrap().kind = ObjectKind::Chunk;
436        },
437        ExpectedError::KindMismatch,
438      ),
439      (
440        "recipe bytes are invalid",
441        |source, recipe_hash, _| {
442          source.0.get_mut(&recipe_hash).unwrap().bytes = vec![0xff];
443        },
444        ExpectedError::InvalidRecipe,
445      ),
446      (
447        "chunk kind is recipe",
448        |source, _, chunk_hash| {
449          source.0.get_mut(&chunk_hash).unwrap().kind = ObjectKind::Recipe;
450        },
451        ExpectedError::KindMismatch,
452      ),
453      (
454        "chunk length mismatch",
455        |source, _, chunk_hash| {
456          source.0.get_mut(&chunk_hash).unwrap().bytes.push(0);
457        },
458        ExpectedError::ChunkLengthMismatch,
459      ),
460    ];
461    let registry = TransformDecoderRegistry::default();
462    for (label, mutate, expected_error) in cases {
463      let (mut source, recipe_hash) = source_for(
464        &[expected.as_slice()],
465        &[0],
466        expected.len() as u64,
467        Hash32::sha3_256(expected),
468        (TRANSFORM_ID_NONE, TRANSFORM_VERSION_NONE),
469      );
470      let chunk_hash = parse_recipe(&source.0[&recipe_hash].bytes).unwrap().chunks[0].0;
471      mutate(&mut source, recipe_hash, chunk_hash);
472      let reader = AsyncFileReader::new(&source, &registry, FileReadLimits::default());
473      for result in [
474        reader.read_file(recipe_hash).await.map(|_| ()),
475        reader.resolve_closure(recipe_hash).await.map(|_| ()),
476      ] {
477        let error = result.unwrap_err();
478        let matched = matches!(
479          (&error, expected_error),
480          (Error::ObjectNotFound, ExpectedError::NotFound)
481            | (Error::ObjectKindMismatch { .. }, ExpectedError::KindMismatch)
482            | (Error::InvalidRecipe(_), ExpectedError::InvalidRecipe)
483            | (Error::RecipeChunkLengthMismatch { .. }, ExpectedError::ChunkLengthMismatch)
484        );
485        assert!(matched, "{label} returned unexpected error: {error}");
486      }
487    }
488  }
489
490  #[tokio::test]
491  async fn limits_and_transform_or_original_mismatch_are_structured() {
492    struct CountingDecoder(Arc<AtomicUsize>);
493    impl crate::TransformDecoder for CountingDecoder {
494      fn id(&self) -> u16 {
495        88
496      }
497      fn version(&self) -> u16 {
498        1
499      }
500      fn decode(&self, input: &mut dyn io::BufRead, output: &mut dyn Write) -> Result<()> {
501        self.0.fetch_add(1, Ordering::Relaxed);
502        io::copy(input, output)?;
503        Ok(())
504      }
505    }
506
507    let bytes = b"alpha";
508    let original_hash = Hash32::sha3_256(bytes);
509    let calls = Arc::new(AtomicUsize::new(0));
510    let registry = TransformDecoderRegistry::new([Arc::new(CountingDecoder(calls.clone())) as Arc<dyn crate::TransformDecoder>]).unwrap();
511    let exact_limits = FileReadLimits {
512      max_chunk_count: 1,
513      max_stored_stream_bytes: bytes.len() as u64,
514      max_original_file_bytes: bytes.len() as u64,
515    };
516    let (source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64, original_hash, (88, 1));
517    let reader = AsyncFileReader::new(&source, &registry, exact_limits);
518    assert_eq!(reader.read_file(recipe_hash).await.unwrap(), bytes);
519    assert_eq!(calls.load(Ordering::Relaxed), 1);
520
521    let declared_size = 8 * 1024 * 1024 * 1024;
522    let (source, recipe_hash) = source_for(&[], &[], declared_size, Hash32::sha3_256([]), (0, 0));
523    let reader = AsyncFileReader::new(&source, &registry, FileReadLimits::default());
524    assert!(matches!(
525      reader.read_file(recipe_hash).await,
526      Err(Error::FileReadLimitExceeded {
527        limit: "original file bytes",
528        actual,
529        ..
530      }) if actual == declared_size
531    ));
532    let reader = AsyncFileReader::new(
533      &source,
534      &registry,
535      FileReadLimits {
536        max_original_file_bytes: declared_size,
537        ..FileReadLimits::default()
538      },
539    );
540    assert!(matches!(
541      reader.read_file(recipe_hash).await,
542      Err(Error::OriginalFileMismatch {
543        expected_size,
544        actual_size: 0,
545        ..
546      }) if expected_size == declared_size
547    ));
548
549    let cases = [
550      (
551        FileReadLimits {
552          max_chunk_count: 0,
553          ..FileReadLimits::default()
554        },
555        "chunk count",
556      ),
557      (
558        FileReadLimits {
559          max_stored_stream_bytes: 4,
560          ..FileReadLimits::default()
561        },
562        "stored stream bytes",
563      ),
564      (
565        FileReadLimits {
566          max_original_file_bytes: 4,
567          ..FileReadLimits::default()
568        },
569        "original file bytes",
570      ),
571    ];
572    for (limits, expected_limit) in cases {
573      let before = calls.load(Ordering::Relaxed);
574      let (source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64, original_hash, (88, 1));
575      let reader = AsyncFileReader::new(&source, &registry, limits);
576      assert!(matches!(
577        reader.read_file(recipe_hash).await,
578        Err(Error::FileReadLimitExceeded { limit, .. }) if limit == expected_limit
579      ));
580      assert_eq!(calls.load(Ordering::Relaxed), before);
581    }
582
583    let (mut source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64, original_hash, (0, 0));
584    source.0.get_mut(&recipe_hash).unwrap().bytes[45..49].copy_from_slice(&u32::MAX.to_le_bytes());
585    let reader = AsyncFileReader::new(
586      &source,
587      &registry,
588      FileReadLimits {
589        max_chunk_count: 0,
590        ..FileReadLimits::default()
591      },
592    );
593    assert!(matches!(
594      reader.read_file(recipe_hash).await,
595      Err(Error::FileReadLimitExceeded { limit: "chunk count", actual, .. }) if actual == u64::from(u32::MAX)
596    ));
597    assert_eq!(calls.load(Ordering::Relaxed), 1);
598
599    let (source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64, original_hash, (TRANSFORM_ID_NONE, 4));
600    let reader = AsyncFileReader::new(&source, &registry, FileReadLimits::default());
601    assert!(matches!(
602      reader.read_file(recipe_hash).await,
603      Err(Error::UnsupportedTransform {
604        id: TRANSFORM_ID_NONE,
605        version: 4
606      })
607    ));
608
609    let (source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64, Hash32::sha3_256(b"other"), (0, 0));
610    let none_registry = TransformDecoderRegistry::default();
611    let reader = AsyncFileReader::new(&source, &none_registry, FileReadLimits::default());
612    assert_eq!(reader.read_stored_stream(recipe_hash).await.unwrap(), bytes);
613    assert!(matches!(
614      reader.read_file(recipe_hash).await,
615      Err(Error::OriginalFileMismatch { .. })
616    ));
617    let mut restored = Vec::new();
618    assert!(matches!(
619      reader.restore_file(recipe_hash, &mut restored).await,
620      Err(Error::OriginalFileMismatch { .. })
621    ));
622
623    let (source, recipe_hash) = source_for(&[bytes], &[0], bytes.len() as u64 + 1, original_hash, (0, 0));
624    let reader = AsyncFileReader::new(&source, &none_registry, FileReadLimits::default());
625    assert!(matches!(
626      reader.read_file(recipe_hash).await,
627      Err(Error::OriginalFileMismatch { expected_size, actual_size, .. }) if expected_size == bytes.len() as u64 + 1 && actual_size == bytes.len() as u64
628    ));
629  }
630
631  #[test]
632  fn registry_accepts_decoder_trait_objects_without_writer_features() {
633    struct Decoder;
634    impl crate::TransformDecoder for Decoder {
635      fn id(&self) -> u16 {
636        91
637      }
638      fn version(&self) -> u16 {
639        2
640      }
641      fn decode(&self, input: &mut dyn io::BufRead, output: &mut dyn Write) -> Result<()> {
642        io::copy(input, output)?;
643        Ok(())
644      }
645    }
646    let registry = TransformDecoderRegistry::new([Arc::new(Decoder) as Arc<dyn crate::TransformDecoder>]).unwrap();
647    assert_eq!(registry.get(91, 2).unwrap().version(), 2);
648  }
649}