Skip to main content

async_zip/base/read/
mod.rs

1// Copyright (c) 2022-2023 Harry [Majored] [hello@majored.pw]
2// MIT License (https://github.com/Majored/rs-async-zip/blob/main/LICENSE)
3
4//! A module which supports reading ZIP files.
5
6pub mod mem;
7pub mod seek;
8pub mod stream;
9
10pub mod cd;
11mod counting;
12pub(crate) mod io;
13
14use crate::ZipString;
15// Re-exported as part of the public API.
16pub use crate::base::read::io::entry::WithEntry;
17pub use crate::base::read::io::entry::WithoutEntry;
18pub use crate::base::read::io::entry::ZipEntryReader;
19
20use crate::date::ZipDateTime;
21use crate::entry::{StoredZipEntry, ZipEntry};
22use crate::error::{Result, ZipError};
23use crate::file::ZipFile;
24use crate::spec::attribute::AttributeCompatibility;
25use crate::spec::consts::{CDDS_LENGTH, CDDS_SIGNATURE, CDH_LENGTH, LFH_LENGTH};
26use crate::spec::consts::{
27    CDH_SIGNATURE, LFH_SIGNATURE, NON_ZIP64_MAX_SIZE, SIGNATURE_LENGTH, ZIP64_EOCDL_LENGTH, ZIP64_EOCDR_SIGNATURE,
28};
29use crate::spec::header::InfoZipUnicodeCommentExtraField;
30use crate::spec::header::InfoZipUnicodePathExtraField;
31use crate::spec::header::{
32    CentralDirectoryRecord, EndOfCentralDirectoryHeader, ExtraField, LocalFileHeader,
33    Zip64EndOfCentralDirectoryLocator, Zip64EndOfCentralDirectoryRecord, Zip64ExtendedInformationExtraField,
34};
35use crate::spec::Compression;
36use crate::string::StringEncoding;
37
38use crate::base::read::io::CombinedCentralDirectoryRecord;
39use crate::spec::parse::parse_extra_fields;
40
41use futures_lite::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, BufReader, SeekFrom};
42
43/// The max buffer size used when parsing the central directory, equal to 20MiB.
44const MAX_CD_BUFFER_SIZE: usize = 20 * 1024 * 1024;
45const MIN_CENTRAL_DIRECTORY_ENTRY_SIZE: u64 = (SIGNATURE_LENGTH + CDH_LENGTH) as u64;
46
47pub(crate) async fn file<R>(mut reader: R) -> Result<ZipFile>
48where
49    R: AsyncRead + AsyncSeek + Unpin,
50{
51    // First find and parse the EOCDR.
52    let eocdr_offset = crate::base::read::io::locator::eocdr(&mut reader).await?;
53    let eocdr_record_offset = eocdr_offset.saturating_sub(SIGNATURE_LENGTH as u64);
54
55    reader.seek(SeekFrom::Start(eocdr_offset)).await?;
56    let eocdr = EndOfCentralDirectoryHeader::from_reader(&mut reader).await?;
57
58    let comment = io::read_string(&mut reader, eocdr.file_comm_length.into(), crate::StringEncoding::Utf8).await?;
59
60    // Check the 20 bytes before the EOCDR for the Zip64 EOCDL, plus an extra 4 bytes because the offset
61    // does not include the signature. If the ECODL exists we are dealing with a Zip64 file.
62    let (eocdr, zip64, central_directory_boundary) =
63        match eocdr_offset.checked_sub(ZIP64_EOCDL_LENGTH + SIGNATURE_LENGTH as u64) {
64            None => (CombinedCentralDirectoryRecord::try_from(&eocdr)?, false, eocdr_record_offset),
65            Some(offset) => {
66                reader.seek(SeekFrom::Start(offset)).await?;
67                let zip64_locator = Zip64EndOfCentralDirectoryLocator::try_from_reader(&mut reader).await?;
68
69                match zip64_locator {
70                    Some(locator) => {
71                        reader.seek(SeekFrom::Start(locator.relative_offset)).await?;
72                        let signature = {
73                            let mut buffer = [0; SIGNATURE_LENGTH];
74                            reader.read_exact(&mut buffer).await?;
75                            u32::from_le_bytes(buffer)
76                        };
77                        if signature != ZIP64_EOCDR_SIGNATURE {
78                            return Err(ZipError::UnexpectedHeaderError(signature, ZIP64_EOCDR_SIGNATURE));
79                        }
80                        let zip64_eocdr = Zip64EndOfCentralDirectoryRecord::from_reader(&mut reader).await?;
81                        validate_zip64_end_record_binding(&zip64_eocdr, locator.relative_offset, offset)?;
82                        validate_zip64_entry_count(&zip64_eocdr, locator.relative_offset)?;
83                        (CombinedCentralDirectoryRecord::combine(eocdr, zip64_eocdr)?, true, locator.relative_offset)
84                    }
85                    None => (CombinedCentralDirectoryRecord::try_from(&eocdr)?, false, eocdr_record_offset),
86                }
87            }
88        };
89
90    validate_central_directory_range(&eocdr, central_directory_boundary)?;
91
92    // Outdated feature so unlikely to ever make it into this crate.
93    if eocdr.disk_number != eocdr.disk_number_start_of_cd
94        || eocdr.num_entries_in_directory != eocdr.num_entries_in_directory_on_disk
95    {
96        return Err(ZipError::FeatureNotSupported("Spanned/split files"));
97    }
98
99    // Find and parse the central directory.
100    reader.seek(SeekFrom::Start(eocdr.offset_of_start_of_directory)).await?;
101
102    // To avoid lots of small reads to `reader` when parsing the central directory, we use a BufReader that can read the whole central directory at once.
103    // Because `eocdr.offset_of_start_of_directory` is a u64, we use MAX_CD_BUFFER_SIZE to prevent very large buffer sizes.
104    let mut buf =
105        BufReader::with_capacity(std::cmp::min(eocdr.offset_of_start_of_directory as _, MAX_CD_BUFFER_SIZE), reader);
106    let mut entries = crate::base::read::cd(
107        &mut buf,
108        eocdr.num_entries_in_directory,
109        eocdr.offset_of_start_of_directory,
110        eocdr.directory_size,
111        zip64,
112    )
113    .await?;
114    validate_central_directory_binding(&eocdr, central_directory_boundary)?;
115    assign_entry_data_boundaries(&mut entries, eocdr.offset_of_start_of_directory);
116
117    Ok(ZipFile { entries, comment, zip64 })
118}
119
120fn validate_zip64_entry_count(zip64_eocdr: &Zip64EndOfCentralDirectoryRecord, zip64_eocdr_offset: u64) -> Result<()> {
121    let minimum_central_directory_end = zip64_eocdr
122        .num_entries_in_directory
123        .saturating_mul(MIN_CENTRAL_DIRECTORY_ENTRY_SIZE)
124        .saturating_add(zip64_eocdr.offset_of_start_of_directory);
125
126    if zip64_eocdr_offset < minimum_central_directory_end {
127        return Err(ZipError::InvalidCentralDirectoryEntryCount { entries: zip64_eocdr.num_entries_in_directory });
128    }
129
130    Ok(())
131}
132
133/// Ensures the ZIP64 end record occupies the entire span before its locator.
134///
135/// The record's size field excludes its signature and the size field itself, so both are added when deriving the
136/// locator's expected offset.
137fn validate_zip64_end_record_binding(
138    zip64_eocdr: &Zip64EndOfCentralDirectoryRecord,
139    zip64_eocdr_offset: u64,
140    zip64_locator_offset: u64,
141) -> Result<()> {
142    let expected_locator_offset = zip64_eocdr_offset
143        .checked_add(SIGNATURE_LENGTH as u64 + 8)
144        .and_then(|offset| offset.checked_add(zip64_eocdr.size_of_zip64_end_of_cd_record))
145        .ok_or(ZipError::InvalidZip64EndOfCentralDirectoryLocatorOffset(u64::MAX, zip64_locator_offset))?;
146
147    if expected_locator_offset != zip64_locator_offset {
148        return Err(ZipError::InvalidZip64EndOfCentralDirectoryLocatorOffset(
149            expected_locator_offset,
150            zip64_locator_offset,
151        ));
152    }
153
154    Ok(())
155}
156
157/// Ensures the declared central-directory span ends exactly where the selected end record begins.
158///
159/// For a ZIP64 archive, `boundary` is the ZIP64 end record; otherwise it is the legacy end record.
160fn validate_central_directory_binding(eocdr: &CombinedCentralDirectoryRecord, boundary: u64) -> Result<()> {
161    let end = eocdr
162        .offset_of_start_of_directory
163        .checked_add(eocdr.directory_size)
164        .ok_or(ZipError::InvalidCentralDirectoryBinding { directory_end: u64::MAX, end_record: boundary })?;
165
166    if end != boundary {
167        return Err(ZipError::InvalidCentralDirectoryBinding { directory_end: end, end_record: boundary });
168    }
169
170    Ok(())
171}
172
173/// Ensures the declared central-directory span does not overlap the selected end record.
174fn validate_central_directory_range(eocdr: &CombinedCentralDirectoryRecord, boundary: u64) -> Result<()> {
175    let start = eocdr.offset_of_start_of_directory;
176    let end = start.checked_add(eocdr.directory_size).ok_or(ZipError::InvalidCentralDirectoryRange {
177        start,
178        end: u64::MAX,
179        boundary,
180    })?;
181
182    if end > boundary {
183        return Err(ZipError::InvalidCentralDirectoryRange { start, end, boundary });
184    }
185
186    Ok(())
187}
188
189fn cd_entry_capacity(num_of_entries: usize, directory_start: u64) -> Result<usize> {
190    let directory_start = usize::try_from(directory_start).unwrap_or(usize::MAX);
191    let capacity = if num_of_entries > directory_start { 0 } else { num_of_entries };
192
193    if capacity.saturating_mul(std::mem::size_of::<StoredZipEntry>()) > isize::MAX as usize {
194        return Err(ZipError::FeatureNotSupported("Oversized central directory"));
195    }
196
197    Ok(capacity)
198}
199
200fn assign_entry_data_boundaries(entries: &mut [StoredZipEntry], directory_start: u64) {
201    let mut local_headers: Vec<_> = entries.iter().map(|entry| entry.file_offset).collect();
202    local_headers.sort_unstable();
203
204    for entry in entries {
205        entry.data_end_boundary = local_headers
206            .get(local_headers.partition_point(|offset| *offset <= entry.file_offset))
207            .copied()
208            .unwrap_or(directory_start)
209            .min(directory_start);
210    }
211}
212
213/// Parses exactly the central-directory span declared by the selected end record.
214///
215/// Once the declared entries have been read, only an optional central-directory digital-signature record may remain.
216pub(crate) async fn cd<R>(
217    reader: R,
218    num_of_entries: u64,
219    directory_start: u64,
220    directory_size: u64,
221    zip64: bool,
222) -> Result<Vec<StoredZipEntry>>
223where
224    R: AsyncRead + Unpin,
225{
226    let claimed_entries = num_of_entries;
227    let num_of_entries: usize = num_of_entries.try_into().map_err(|_| ZipError::TargetZip64NotSupported)?;
228    let mut entries = Vec::with_capacity(cd_entry_capacity(num_of_entries, directory_start)?);
229    let mut remaining_directory_size = directory_size;
230    let mut reader = counting::Counting::new(reader);
231
232    for _ in 0..num_of_entries {
233        let entry = cd_record(&mut reader, zip64, &mut remaining_directory_size, claimed_entries).await?;
234        entries.push(entry);
235    }
236
237    consume_central_directory_digital_signature(&mut reader, directory_size).await?;
238
239    let actual = reader.bytes_read();
240    if actual != directory_size {
241        return Err(ZipError::InvalidCentralDirectorySize { expected: directory_size, actual });
242    }
243
244    Ok(entries)
245}
246
247/// Consumes an optional digital-signature record that exactly fills the unparsed central-directory span.
248///
249/// Returning without reading is valid only when the declared entries already consumed the full span. Other trailing
250/// bytes, truncated records, and length claims that do not reach `directory_size` are rejected.
251async fn consume_central_directory_digital_signature<R>(
252    reader: &mut counting::Counting<R>,
253    directory_size: u64,
254) -> Result<()>
255where
256    R: AsyncRead + Unpin,
257{
258    let actual = reader.bytes_read();
259    if actual >= directory_size {
260        return Ok(());
261    }
262
263    let fixed_length = (SIGNATURE_LENGTH + CDDS_LENGTH) as u64;
264    if directory_size - actual < fixed_length {
265        return Err(ZipError::InvalidCentralDirectorySize { expected: directory_size, actual });
266    }
267
268    let mut signature = [0; SIGNATURE_LENGTH];
269    reader.read_exact(&mut signature).await?;
270    if u32::from_le_bytes(signature) != CDDS_SIGNATURE {
271        return Err(ZipError::InvalidCentralDirectorySize { expected: directory_size, actual });
272    }
273
274    let mut length = [0; CDDS_LENGTH];
275    reader.read_exact(&mut length).await?;
276    let signature_length = u16::from_le_bytes(length) as u64;
277    let Some(record_end) =
278        actual.checked_add(fixed_length).and_then(|record_end| record_end.checked_add(signature_length))
279    else {
280        return Err(ZipError::InvalidCentralDirectorySize { expected: directory_size, actual: u64::MAX });
281    };
282    if record_end != directory_size {
283        return Err(ZipError::InvalidCentralDirectorySize { expected: directory_size, actual: record_end });
284    }
285
286    io::skip_bytes(reader, signature_length).await?;
287    Ok(())
288}
289
290pub(crate) fn get_zip64_extra_field(extra_fields: &[ExtraField]) -> Option<&Zip64ExtendedInformationExtraField> {
291    for field in extra_fields {
292        if let ExtraField::Zip64ExtendedInformation(zip64field) = field {
293            return Some(zip64field);
294        }
295    }
296    None
297}
298
299pub(crate) fn get_zip64_extra_field_mut(
300    extra_fields: &mut [ExtraField],
301) -> Option<&mut Zip64ExtendedInformationExtraField> {
302    for field in extra_fields {
303        if let ExtraField::Zip64ExtendedInformation(zip64field) = field {
304            return Some(zip64field);
305        }
306    }
307    None
308}
309
310pub(crate) fn get_combined_sizes(
311    uncompressed_size: u32,
312    compressed_size: u32,
313    extra_field: &Option<&Zip64ExtendedInformationExtraField>,
314) -> Result<(u64, u64)> {
315    let mut uncompressed_size = uncompressed_size as u64;
316    let mut compressed_size = compressed_size as u64;
317
318    if uncompressed_size == NON_ZIP64_MAX_SIZE as u64 {
319        uncompressed_size =
320            extra_field.and_then(|field| field.uncompressed_size).ok_or(ZipError::Zip64ExtendedFieldIncomplete)?;
321    }
322    if compressed_size == NON_ZIP64_MAX_SIZE as u64 {
323        compressed_size =
324            extra_field.and_then(|field| field.compressed_size).ok_or(ZipError::Zip64ExtendedFieldIncomplete)?;
325    }
326
327    Ok((uncompressed_size, compressed_size))
328}
329
330pub(crate) async fn cd_record<R>(
331    mut reader: R,
332    _zip64: bool,
333    remaining_directory_size: &mut u64,
334    claimed_entries: u64,
335) -> Result<StoredZipEntry>
336where
337    R: AsyncRead + Unpin,
338{
339    if *remaining_directory_size < MIN_CENTRAL_DIRECTORY_ENTRY_SIZE {
340        return Err(ZipError::InvalidCentralDirectoryEntryCount { entries: claimed_entries });
341    }
342
343    crate::utils::assert_signature(&mut reader, CDH_SIGNATURE).await?;
344
345    let header = CentralDirectoryRecord::from_reader(&mut reader).await?;
346    let central_directory_entry_size = MIN_CENTRAL_DIRECTORY_ENTRY_SIZE
347        + header.file_name_length as u64
348        + header.extra_field_length as u64
349        + header.file_comment_length as u64;
350    if *remaining_directory_size < central_directory_entry_size {
351        return Err(ZipError::InvalidCentralDirectoryEntryCount { entries: claimed_entries });
352    }
353    *remaining_directory_size -= central_directory_entry_size;
354
355    let header_size = (SIGNATURE_LENGTH + LFH_LENGTH) as u64;
356    let trailing_size = header.file_name_length as u64 + header.extra_field_length as u64;
357    let filename_basic = io::read_bytes(&mut reader, header.file_name_length.into()).await?;
358    let compression = Compression::try_from(header.compression)?;
359    let extra_field = io::read_bytes(&mut reader, header.extra_field_length.into()).await?;
360    let extra_fields = parse_extra_fields(
361        extra_field,
362        header.uncompressed_size,
363        header.compressed_size,
364        Some(header.lh_offset),
365        Some(header.disk_start),
366    )?;
367    let comment_basic = io::read_bytes(reader, header.file_comment_length.into()).await?;
368
369    let zip64_extra_field = get_zip64_extra_field(&extra_fields);
370    let (uncompressed_size, compressed_size) =
371        get_combined_sizes(header.uncompressed_size, header.compressed_size, &zip64_extra_field)?;
372
373    let mut file_offset = header.lh_offset as u64;
374    if let Some(zip64_extra_field) = zip64_extra_field {
375        if file_offset == NON_ZIP64_MAX_SIZE as u64 {
376            if let Some(offset) = zip64_extra_field.relative_header_offset {
377                file_offset = offset;
378            }
379        }
380    }
381
382    let filename = detect_filename(filename_basic, header.flags.filename_unicode, extra_fields.as_ref())?;
383    let comment = detect_comment(comment_basic, header.flags.filename_unicode, extra_fields.as_ref());
384
385    let entry = ZipEntry {
386        filename,
387        compression,
388        #[cfg(any(
389            feature = "deflate",
390            feature = "bzip2",
391            feature = "zstd",
392            feature = "lzma",
393            feature = "xz",
394            feature = "deflate64"
395        ))]
396        compression_level: async_compression::Level::Default,
397        attribute_compatibility: AttributeCompatibility::Unix,
398        // FIXME: Default to Unix for the moment
399        crc32: header.crc,
400        uncompressed_size,
401        compressed_size,
402        last_modification_date: ZipDateTime { date: header.mod_date, time: header.mod_time },
403        internal_file_attribute: header.inter_attr,
404        external_file_attribute: header.exter_attr,
405        extra_fields,
406        comment,
407        data_descriptor: header.flags.data_descriptor,
408        file_offset,
409    };
410
411    Ok(StoredZipEntry { entry, file_offset, header_size: header_size + trailing_size, data_end_boundary: u64::MAX })
412}
413
414pub(crate) async fn lfh<R>(mut reader: R, file_offset: u64) -> Result<Option<ZipEntry>>
415where
416    R: AsyncRead + Unpin,
417{
418    let signature = {
419        let mut buffer = [0; 4];
420        reader.read_exact(&mut buffer).await?;
421        u32::from_le_bytes(buffer)
422    };
423    match signature {
424        actual if actual == LFH_SIGNATURE => (),
425        actual if actual == CDH_SIGNATURE => return Ok(None),
426        actual => return Err(ZipError::UnexpectedHeaderError(actual, LFH_SIGNATURE)),
427    };
428
429    let header = LocalFileHeader::from_reader(&mut reader).await?;
430    let filename_basic = io::read_bytes(&mut reader, header.file_name_length.into()).await?;
431    let compression = Compression::try_from(header.compression)?;
432    let extra_field = io::read_bytes(&mut reader, header.extra_field_length.into()).await?;
433    let extra_fields = parse_extra_fields(extra_field, header.uncompressed_size, header.compressed_size, None, None)?;
434
435    let zip64_extra_field = get_zip64_extra_field(&extra_fields);
436    let (uncompressed_size, compressed_size) =
437        get_combined_sizes(header.uncompressed_size, header.compressed_size, &zip64_extra_field)?;
438
439    if header.flags.data_descriptor && compression == Compression::Stored {
440        return Err(ZipError::FeatureNotSupported(
441            "stream reading entries with data descriptors & Stored compression mode",
442        ));
443    }
444    let filename = detect_filename(filename_basic, header.flags.filename_unicode, extra_fields.as_ref())?;
445
446    let entry = ZipEntry {
447        filename,
448        compression,
449        #[cfg(any(
450            feature = "deflate",
451            feature = "bzip2",
452            feature = "zstd",
453            feature = "lzma",
454            feature = "xz",
455            feature = "deflate64"
456        ))]
457        compression_level: async_compression::Level::Default,
458        attribute_compatibility: AttributeCompatibility::Unix,
459        // FIXME: Default to Unix for the moment
460        crc32: header.crc,
461        uncompressed_size,
462        compressed_size,
463        last_modification_date: ZipDateTime { date: header.mod_date, time: header.mod_time },
464        internal_file_attribute: 0,
465        external_file_attribute: 0,
466        extra_fields,
467        comment: String::new().into(),
468        data_descriptor: header.flags.data_descriptor,
469        file_offset,
470    };
471
472    Ok(Some(entry))
473}
474
475fn detect_comment(basic: Vec<u8>, basic_is_utf8: bool, extra_fields: &[ExtraField]) -> ZipString {
476    if basic_is_utf8 {
477        ZipString::new(basic, StringEncoding::Utf8)
478    } else {
479        let unicode_extra = extra_fields.iter().find_map(|field| match field {
480            ExtraField::InfoZipUnicodeComment(InfoZipUnicodeCommentExtraField::V1 { crc32, unicode }) => {
481                if *crc32 == crc32fast::hash(&basic) {
482                    Some(std::string::String::from_utf8(unicode.clone()))
483                } else {
484                    None
485                }
486            }
487            _ => None,
488        });
489        if let Some(Ok(s)) = unicode_extra {
490            ZipString::new_with_alternative(s, basic)
491        } else {
492            // Do not treat as UTF-8 if UTF-8 flags are not set,
493            // some string in MBCS may be valid UTF-8 in form, but they are not in truth.
494            if basic.is_ascii() {
495                // SAFETY:
496                // a valid ASCII string is always a valid UTF-8 string
497                unsafe { std::string::String::from_utf8_unchecked(basic).into() }
498            } else {
499                ZipString::new(basic, StringEncoding::Raw)
500            }
501        }
502    }
503}
504
505fn detect_filename(basic: Vec<u8>, basic_is_utf8: bool, extra_fields: &[ExtraField]) -> Result<ZipString> {
506    let unicode_extra = extra_fields.iter().find_map(|field| match field {
507        ExtraField::InfoZipUnicodePath(InfoZipUnicodePathExtraField::V1 { crc32, unicode }) => {
508            if !unicode.is_empty() && *crc32 == crc32fast::hash(&basic) {
509                Some(std::string::String::from_utf8(unicode.clone()))
510            } else {
511                None
512            }
513        }
514        _ => None,
515    });
516    if basic.contains(&0) {
517        return Err(ZipError::FileNameContainsNul { filename: basic });
518    }
519    if let Some(Ok(filename)) = unicode_extra.as_ref() {
520        if filename.contains('\0') {
521            return Err(ZipError::FileNameContainsNul { filename: filename.as_bytes().to_vec() });
522        }
523    }
524    if let Some(unicode_extra) = unicode_extra {
525        let unicode = unicode_extra.map_err(|_| ZipError::InfoZipUnicodePathFieldInvalidUtf8)?;
526        Ok(ZipString::new_with_alternative(unicode, basic))
527    } else if basic_is_utf8 {
528        Ok(ZipString::new(basic, StringEncoding::Utf8))
529    } else {
530        // Do not treat as UTF-8 if UTF-8 flags are not set,
531        // some string in MBCS may be valid UTF-8 in form, but they are not in truth.
532        if basic.is_ascii() {
533            // SAFETY:
534            // a valid ASCII string is always a valid UTF-8 string
535            Ok(unsafe { std::string::String::from_utf8_unchecked(basic).into() })
536        } else {
537            Ok(ZipString::new(basic, StringEncoding::Raw))
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn invalid_matching_unicode_path_extra_field_is_rejected() {
548        let basic = b"basic.txt".to_vec();
549        let fields = [ExtraField::InfoZipUnicodePath(InfoZipUnicodePathExtraField::V1 {
550            crc32: crc32fast::hash(&basic),
551            unicode: vec![0xFF],
552        })];
553
554        assert!(matches!(detect_filename(basic, false, &fields), Err(ZipError::InfoZipUnicodePathFieldInvalidUtf8)));
555    }
556
557    #[test]
558    fn invalid_non_matching_unicode_path_extra_field_is_ignored() {
559        let basic = b"basic.txt".to_vec();
560        let fields =
561            [ExtraField::InfoZipUnicodePath(InfoZipUnicodePathExtraField::V1 { crc32: 0, unicode: vec![0xFF] })];
562
563        assert_eq!(detect_filename(basic, false, &fields).unwrap().as_str().unwrap(), "basic.txt");
564    }
565}