Skip to main content

tzap_core/
reader.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::io::Read;
3
4use sha2::{Digest, Sha256};
5
6use crate::compression::{
7    decompress_exact_zstd_frame, decompress_exact_zstd_frame_with_dictionary,
8    validate_exact_zstd_frame,
9};
10use crate::crypto::{decrypt_padded_aead_object, verify_hmac, HmacDomain, MasterKey, Subkeys};
11use crate::fec::repair_data_gf16;
12use crate::format::{
13    BlockKind, FormatError, BLOCK_RECORD_FRAMING_LEN, BOOTSTRAP_SIDECAR_HEADER_LEN,
14    MANIFEST_FOOTER_LEN, VOLUME_HEADER_LEN, VOLUME_TRAILER_LEN,
15};
16use crate::metadata::{
17    hash_prefix, normalize_lookup_file_path, DirectoryHintShardEntry, DirectoryHintTable,
18    EnvelopeEntry, FileEntry, FrameEntry, IndexRoot, IndexShard, MetadataLimits, ShardEntry,
19};
20use crate::tar_model::{
21    parse_tar_member_group, restore_tar_member, validate_tar_stream_total_extraction_size,
22    MetadataDiagnostic, OwnedTarMember, SafeExtractionOptions, TarEntryKind,
23};
24use crate::wire::{
25    BlockRecord, BootstrapSidecarHeader, CryptoHeader, CryptoHeaderFixed, ManifestFooter,
26    VolumeHeader, VolumeTrailer,
27};
28
29const TRAILER_HMAC_COVERED_LEN: usize = 96;
30const MANIFEST_HMAC_COVERED_LEN: usize = 104;
31const SIDECAR_HMAC_COVERED_LEN: usize = 92;
32const DEFAULT_MAX_VERIFY_TAR_SIZE: usize = 128 * 1024 * 1024;
33const DEFAULT_MAX_TRAILING_GARBAGE_SCAN: usize = 1024 * 1024;
34const DEFAULT_MAX_TOTAL_EXTRACTION_SIZE: u64 = 100 * 1024 * 1024 * 1024;
35const DIRECTORY_HINT_REQUIRED_FILE_COUNT: u64 = 100_000;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct ReaderOptions {
39    pub max_trailing_garbage_scan: usize,
40    pub max_verify_tar_size: usize,
41    pub max_total_extraction_size: u64,
42}
43
44impl Default for ReaderOptions {
45    fn default() -> Self {
46        Self {
47            max_trailing_garbage_scan: DEFAULT_MAX_TRAILING_GARBAGE_SCAN,
48            max_verify_tar_size: DEFAULT_MAX_VERIFY_TAR_SIZE,
49            max_total_extraction_size: DEFAULT_MAX_TOTAL_EXTRACTION_SIZE,
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ArchiveEntry {
56    pub path: String,
57    pub file_data_size: u64,
58    pub kind: TarEntryKind,
59    pub mode: u32,
60    pub mtime: u64,
61    pub diagnostics: Vec<MetadataDiagnostic>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ExtractedArchiveMember {
66    pub path: String,
67    pub kind: TarEntryKind,
68    pub data: Vec<u8>,
69    pub link_target: Option<String>,
70    pub diagnostics: Vec<MetadataDiagnostic>,
71}
72
73#[derive(Debug, Clone)]
74pub struct OpenedArchive {
75    options: ReaderOptions,
76    observed_archive_bytes: u64,
77    subkeys: Subkeys,
78    blocks: BTreeMap<u64, BlockRecord>,
79    pub volume_header: VolumeHeader,
80    pub crypto_header: CryptoHeaderFixed,
81    pub manifest_footer: ManifestFooter,
82    pub volume_trailer: VolumeTrailer,
83    pub index_root: IndexRoot,
84    payload_dictionary: Option<Vec<u8>>,
85}
86
87#[derive(Debug, Clone, Copy)]
88struct ObjectExtent {
89    first_block_index: u64,
90    data_block_count: u32,
91    parity_block_count: u32,
92    encrypted_size: u32,
93}
94
95type DirectoryHintMap = BTreeMap<Vec<u8>, BTreeSet<u32>>;
96
97pub fn open_archive<'a>(
98    bytes: &'a [u8],
99    master_key: &MasterKey,
100) -> Result<OpenedArchive, FormatError> {
101    OpenedArchive::open_with_options(bytes, master_key, ReaderOptions::default())
102}
103
104pub fn open_archive_volumes(
105    volumes: &[&[u8]],
106    master_key: &MasterKey,
107) -> Result<OpenedArchive, FormatError> {
108    OpenedArchive::open_volumes_with_options(volumes, master_key, ReaderOptions::default())
109}
110
111pub fn open_archive_with_bootstrap_sidecar(
112    bytes: &[u8],
113    bootstrap_sidecar: &[u8],
114    master_key: &MasterKey,
115) -> Result<OpenedArchive, FormatError> {
116    OpenedArchive::open_with_bootstrap_sidecar_options(
117        bytes,
118        bootstrap_sidecar,
119        master_key,
120        ReaderOptions::default(),
121    )
122}
123
124pub fn open_non_seekable_archive(
125    bytes: &[u8],
126    master_key: &MasterKey,
127    bootstrap_sidecar: Option<&[u8]>,
128) -> Result<OpenedArchive, FormatError> {
129    match bootstrap_sidecar {
130        Some(sidecar) => open_archive_with_bootstrap_sidecar(bytes, sidecar, master_key),
131        None => Err(FormatError::ReaderUnsupported(
132            "non-seekable random access requires a bootstrap sidecar",
133        )),
134    }
135}
136
137pub fn sequential_extract_tar_stream(
138    bytes: &[u8],
139    master_key: &MasterKey,
140) -> Result<Vec<u8>, FormatError> {
141    sequential_extract_tar_stream_with_options(bytes, master_key, ReaderOptions::default())
142}
143
144impl OpenedArchive {
145    pub fn open_with_options(
146        bytes: &[u8],
147        master_key: &MasterKey,
148        options: ReaderOptions,
149    ) -> Result<Self, FormatError> {
150        Self::open_volumes_with_options(&[bytes], master_key, options)
151    }
152
153    pub fn open_volumes_with_options(
154        volumes: &[&[u8]],
155        master_key: &MasterKey,
156        options: ReaderOptions,
157    ) -> Result<Self, FormatError> {
158        if volumes.is_empty() {
159            return Err(FormatError::InvalidArchive("no volumes supplied"));
160        }
161
162        let observed_archive_bytes =
163            observed_archive_size(volumes.iter().map(|volume| volume.len() as u64))?;
164        let mut first: Option<ParsedSeekableVolume> = None;
165        let mut seen_volume_indexes = BTreeSet::new();
166        let mut blocks = BTreeMap::new();
167        let mut erased_block_indices = BTreeSet::new();
168
169        for volume_bytes in volumes {
170            let parsed = parse_seekable_volume(volume_bytes, master_key, options)?;
171            if !seen_volume_indexes.insert(parsed.volume_header.volume_index) {
172                return Err(FormatError::InvalidArchive(
173                    "duplicate authenticated volume index",
174                ));
175            }
176
177            if let Some(first) = &first {
178                validate_volume_set_member(first, &parsed)?;
179            }
180
181            for (block_index, record) in &parsed.blocks {
182                if blocks.insert(*block_index, record.clone()).is_some() {
183                    return Err(FormatError::InvalidArchive("duplicate BlockRecord index"));
184                }
185            }
186            for block_index in &parsed.erased_block_indices {
187                erased_block_indices.insert(*block_index);
188            }
189
190            if first.is_none() {
191                first = Some(parsed);
192            }
193        }
194
195        let first = first.ok_or(FormatError::InvalidArchive("no volumes supplied"))?;
196        if seen_volume_indexes.len() == first.crypto_header.stripe_width as usize {
197            validate_complete_global_block_coverage(&blocks, &erased_block_indices)?;
198        }
199
200        let limits = metadata_limits(&first.crypto_header);
201        let index_root_plaintext = load_metadata_object_from_parts(
202            &blocks,
203            &first.subkeys,
204            &first.volume_header,
205            &first.crypto_header,
206            ObjectExtent {
207                first_block_index: first.manifest_footer.index_root_first_block,
208                data_block_count: first.manifest_footer.index_root_data_block_count,
209                parity_block_count: first.manifest_footer.index_root_parity_block_count,
210                encrypted_size: first.manifest_footer.index_root_encrypted_size,
211            },
212            BlockKind::IndexRootData,
213            BlockKind::IndexRootParity,
214            &first.subkeys.index_root_key,
215            &first.subkeys.index_nonce_seed,
216            b"idxroot",
217            0,
218            first.crypto_header.index_root_fec_data_shards,
219            first.crypto_header.index_root_fec_parity_shards,
220            first.manifest_footer.index_root_decompressed_size,
221        )?;
222        let index_root = IndexRoot::parse(
223            &index_root_plaintext,
224            first.crypto_header.has_dictionary != 0,
225            limits,
226        )?;
227        let payload_dictionary = load_archive_dictionary(
228            &blocks,
229            &first.subkeys,
230            &first.volume_header,
231            &first.crypto_header,
232            &index_root,
233        )?;
234
235        Ok(Self {
236            options,
237            observed_archive_bytes,
238            subkeys: first.subkeys,
239            blocks,
240            volume_header: first.volume_header,
241            crypto_header: first.crypto_header,
242            manifest_footer: first.manifest_footer,
243            volume_trailer: first.volume_trailer,
244            index_root,
245            payload_dictionary,
246        })
247    }
248
249    pub fn open_with_bootstrap_sidecar_options(
250        bytes: &[u8],
251        bootstrap_sidecar: &[u8],
252        master_key: &MasterKey,
253        options: ReaderOptions,
254    ) -> Result<Self, FormatError> {
255        let observed_archive_bytes =
256            observed_archive_size([bytes.len() as u64, bootstrap_sidecar.len() as u64])?;
257        if bytes.len() < VOLUME_HEADER_LEN {
258            return Err(FormatError::InvalidLength {
259                structure: "archive",
260                expected: VOLUME_HEADER_LEN,
261                actual: bytes.len(),
262            });
263        }
264
265        let volume_header = VolumeHeader::parse(slice(bytes, 0, VOLUME_HEADER_LEN, "archive")?)?;
266        let crypto_start = volume_header.crypto_header_offset as usize;
267        let crypto_len = volume_header.crypto_header_length as usize;
268        let crypto_end = checked_add(crypto_start, crypto_len, "CryptoHeader")?;
269        let crypto_bytes = slice(bytes, crypto_start, crypto_len, "CryptoHeader")?;
270        let parsed_crypto = CryptoHeader::parse(crypto_bytes, volume_header.crypto_header_length)?;
271        let subkeys = Subkeys::derive(
272            master_key,
273            &volume_header.archive_uuid,
274            &volume_header.session_id,
275        )?;
276        verify_hmac(
277            HmacDomain::CryptoHeader,
278            &subkeys.mac_key,
279            &volume_header.archive_uuid,
280            &volume_header.session_id,
281            parsed_crypto.hmac_covered_bytes,
282            &parsed_crypto.header_hmac,
283        )?;
284        parsed_crypto.validate_extension_semantics()?;
285        validate_m9_supported_volume(&volume_header, &parsed_crypto.fixed)?;
286
287        let sidecar = parse_trusted_bootstrap_sidecar(
288            bootstrap_sidecar,
289            &volume_header,
290            &parsed_crypto.fixed,
291            &subkeys,
292        )?;
293
294        let (mut blocks, terminal_offset, observed_block_count) = parse_stream_block_prefix(
295            bytes,
296            crypto_end,
297            parsed_crypto.fixed.block_size as usize,
298            &volume_header,
299        )?;
300        insert_sidecar_records(&mut blocks, sidecar.index_root_records)?;
301
302        let (terminal_manifest, volume_trailer) = parse_terminal_material(
303            bytes,
304            terminal_offset,
305            observed_block_count,
306            &subkeys,
307            &volume_header,
308            parsed_crypto.fixed.block_size,
309        )?;
310        if terminal_manifest != sidecar.manifest_footer {
311            return Err(FormatError::InvalidArchive(
312                "bootstrap sidecar conflicts with terminal ManifestFooter",
313            ));
314        }
315
316        let limits = metadata_limits(&parsed_crypto.fixed);
317        let index_root_plaintext = load_metadata_object_from_parts(
318            &blocks,
319            &subkeys,
320            &volume_header,
321            &parsed_crypto.fixed,
322            ObjectExtent {
323                first_block_index: sidecar.manifest_footer.index_root_first_block,
324                data_block_count: sidecar.manifest_footer.index_root_data_block_count,
325                parity_block_count: sidecar.manifest_footer.index_root_parity_block_count,
326                encrypted_size: sidecar.manifest_footer.index_root_encrypted_size,
327            },
328            BlockKind::IndexRootData,
329            BlockKind::IndexRootParity,
330            &subkeys.index_root_key,
331            &subkeys.index_nonce_seed,
332            b"idxroot",
333            0,
334            parsed_crypto.fixed.index_root_fec_data_shards,
335            parsed_crypto.fixed.index_root_fec_parity_shards,
336            sidecar.manifest_footer.index_root_decompressed_size,
337        )?;
338        let index_root = IndexRoot::parse(
339            &index_root_plaintext,
340            parsed_crypto.fixed.has_dictionary != 0,
341            limits,
342        )?;
343        if parsed_crypto.fixed.has_dictionary != 0 {
344            let (offset, length) =
345                sidecar
346                    .dictionary_records_section
347                    .ok_or(FormatError::ReaderUnsupported(
348                        "dictionary bootstrap required",
349                    ))?;
350            let dictionary_records = parse_sidecar_block_records(
351                bootstrap_sidecar,
352                offset,
353                length,
354                parsed_crypto.fixed.block_size as usize,
355                dictionary_extent_from_index_root(&index_root)?,
356                BlockKind::DictionaryData,
357                BlockKind::DictionaryParity,
358                "dictionary",
359            )?;
360            insert_sidecar_records(&mut blocks, dictionary_records)?;
361        }
362        let payload_dictionary = load_archive_dictionary(
363            &blocks,
364            &subkeys,
365            &volume_header,
366            &parsed_crypto.fixed,
367            &index_root,
368        )?;
369
370        Ok(Self {
371            options,
372            observed_archive_bytes,
373            subkeys,
374            blocks,
375            volume_header,
376            crypto_header: parsed_crypto.fixed,
377            manifest_footer: sidecar.manifest_footer,
378            volume_trailer,
379            index_root,
380            payload_dictionary,
381        })
382    }
383
384    pub fn list_files(&self) -> Result<Vec<ArchiveEntry>, FormatError> {
385        #[derive(Clone, Copy)]
386        struct WinningEntry {
387            start: u64,
388            file_data_size: u64,
389            shard_index: usize,
390            file_index: usize,
391        }
392
393        let shards = self.load_all_index_shards()?;
394        let mut final_entries = BTreeMap::<String, WinningEntry>::new();
395        for (shard_index, shard) in shards.iter().enumerate() {
396            for (idx, file) in shard.files.iter().enumerate() {
397                let path = utf8_path(
398                    shard
399                        .file_path(idx)
400                        .ok_or(FormatError::InvalidArchive("FileEntry path is missing"))?,
401                )?;
402                let start =
403                    shard
404                        .tar_member_group_start(idx)
405                        .ok_or(FormatError::InvalidArchive(
406                            "FileEntry tar member start is missing",
407                        ))?;
408                if let Some(winner) = final_entries.get_mut(&path) {
409                    if start >= winner.start {
410                        winner.start = start;
411                        winner.file_data_size = file.file_data_size;
412                        winner.shard_index = shard_index;
413                        winner.file_index = idx;
414                    }
415                } else {
416                    final_entries.insert(
417                        path,
418                        WinningEntry {
419                            start,
420                            file_data_size: file.file_data_size,
421                            shard_index,
422                            file_index: idx,
423                        },
424                    );
425                }
426            }
427        }
428        final_entries
429            .into_iter()
430            .map(|(path, winner)| {
431                let shard = &shards[winner.shard_index];
432                let member =
433                    self.decode_loaded_owned_tar_member(shard, winner.file_index, false)?;
434                Ok(ArchiveEntry {
435                    path,
436                    file_data_size: winner.file_data_size,
437                    kind: member.kind,
438                    mode: member.mode,
439                    mtime: member.mtime,
440                    diagnostics: member.diagnostics,
441                })
442            })
443            .collect()
444    }
445
446    pub fn extract_file(&self, path: &str) -> Result<Option<Vec<u8>>, FormatError> {
447        self.extract_member(path)?
448            .map(|member| {
449                if member.kind != TarEntryKind::Regular {
450                    return Err(FormatError::ReaderUnsupported(
451                        "extract_file returns only regular file payloads",
452                    ));
453                }
454                Ok(member.data)
455            })
456            .transpose()
457    }
458
459    pub fn extract_member(
460        &self,
461        path: &str,
462    ) -> Result<Option<ExtractedArchiveMember>, FormatError> {
463        let normalized = normalize_lookup_file_path(path, self.crypto_header.max_path_length)?;
464        let candidate_indexes = self
465            .index_root
466            .candidate_shards_for_path(&normalized, self.metadata_limits())?;
467        let mut winner: Option<(IndexShard, usize, u64)> = None;
468
469        for row_index in candidate_indexes {
470            let locating =
471                self.index_root
472                    .shards
473                    .get(row_index)
474                    .ok_or(FormatError::InvalidArchive(
475                        "candidate shard row is out of bounds",
476                    ))?;
477            let shard = self.load_index_shard(locating)?;
478            if let Some(file_index) = shard.lookup_file_index(&normalized) {
479                let start =
480                    shard
481                        .tar_member_group_start(file_index)
482                        .ok_or(FormatError::InvalidArchive(
483                            "FileEntry tar member start is missing",
484                        ))?;
485                if winner
486                    .as_ref()
487                    .map(|(_, _, best_start)| start > *best_start)
488                    .unwrap_or(true)
489                {
490                    winner = Some((shard, file_index, start));
491                }
492            }
493        }
494
495        winner
496            .map(|(shard, file_index, _)| self.extract_loaded_member(&shard, file_index))
497            .transpose()
498    }
499
500    pub fn extract_file_to(
501        &self,
502        path: &str,
503        root: &std::path::Path,
504        options: SafeExtractionOptions,
505    ) -> Result<Option<Vec<MetadataDiagnostic>>, FormatError> {
506        self.extract_owned_tar_member(path)?
507            .map(|member| restore_tar_member(root, &member, options))
508            .transpose()
509    }
510
511    pub fn verify(&self) -> Result<(), FormatError> {
512        let shards = self.load_all_index_shards()?;
513        let mut file_count = 0u64;
514        let mut frames = BTreeMap::<u64, FrameEntry>::new();
515        let mut envelopes = BTreeMap::<u64, EnvelopeEntry>::new();
516
517        for shard in &shards {
518            file_count = file_count
519                .checked_add(shard.files.len() as u64)
520                .ok_or(FormatError::InvalidArchive("file count overflow"))?;
521            for frame in &shard.frames {
522                if let Some(existing) = frames.insert(frame.frame_index, frame.clone()) {
523                    if existing != *frame {
524                        return Err(FormatError::InvalidArchive(
525                            "duplicate FrameEntry rows do not match",
526                        ));
527                    }
528                }
529            }
530            for envelope in &shard.envelopes {
531                if let Some(existing) = envelopes.insert(envelope.envelope_index, envelope.clone())
532                {
533                    if existing != *envelope {
534                        return Err(FormatError::InvalidArchive(
535                            "duplicate EnvelopeEntry rows do not match",
536                        ));
537                    }
538                }
539            }
540        }
541
542        if file_count != self.index_root.header.file_count {
543            return Err(FormatError::InvalidArchive(
544                "IndexRoot file_count does not match decoded shards",
545            ));
546        }
547        if self.index_root.header.file_count > DIRECTORY_HINT_REQUIRED_FILE_COUNT
548            && self.index_root.directory_hint_shards.is_empty()
549        {
550            return Err(FormatError::InvalidArchive(
551                "IndexRoot file_count requires directory hints",
552            ));
553        }
554        verify_dense_keys(&frames, self.index_root.header.frame_count, "FrameEntry")?;
555        verify_dense_keys(
556            &envelopes,
557            self.index_root.header.envelope_count,
558            "EnvelopeEntry",
559        )?;
560        validate_envelope_frame_coverage(&frames, &envelopes)?;
561        self.validate_encrypted_object_block_ranges(&envelopes)?;
562
563        let payload_block_count = envelopes.values().try_fold(0u64, |sum, envelope| {
564            sum.checked_add(envelope.data_block_count as u64)
565                .ok_or(FormatError::InvalidArchive("payload block count overflow"))
566        })?;
567        if payload_block_count != self.index_root.header.payload_block_count {
568            return Err(FormatError::InvalidArchive(
569                "IndexRoot payload_block_count does not match envelopes",
570            ));
571        }
572
573        let tar_len = to_usize(self.index_root.header.tar_total_size, "tar stream")?;
574        if tar_len > self.options.max_verify_tar_size {
575            return Err(FormatError::ReaderUnsupported(
576                "verify tar stream exceeds configured in-memory cap",
577            ));
578        }
579        let mut tar_stream = vec![0u8; tar_len];
580        let mut covered = vec![false; tar_len];
581        let mut envelope_cache = HashMap::<u64, Vec<u8>>::new();
582
583        for frame in frames.values() {
584            let envelope =
585                envelopes
586                    .get(&frame.envelope_index)
587                    .ok_or(FormatError::InvalidArchive(
588                        "FrameEntry references missing EnvelopeEntry",
589                    ))?;
590            if !envelope_cache.contains_key(&envelope.envelope_index) {
591                envelope_cache.insert(
592                    envelope.envelope_index,
593                    self.load_payload_envelope(envelope)?,
594                );
595            }
596            let envelope_plaintext = envelope_cache
597                .get(&envelope.envelope_index)
598                .expect("inserted above");
599            let compressed = slice(
600                envelope_plaintext,
601                frame.offset_in_envelope as usize,
602                frame.compressed_size as usize,
603                "FrameEntry",
604            )?;
605            let decoded = self.decompress_payload_frame(compressed, frame.decompressed_size)?;
606            let start = to_usize(frame.tar_stream_offset, "tar stream")?;
607            let end = checked_add(start, decoded.len(), "tar stream")?;
608            if end > tar_stream.len() {
609                return Err(FormatError::InvalidArchive(
610                    "FrameEntry exceeds IndexRoot tar_total_size",
611                ));
612            }
613            if covered[start..end].iter().any(|value| *value) {
614                return Err(FormatError::InvalidArchive("decoded frames overlap"));
615            }
616            tar_stream[start..end].copy_from_slice(&decoded);
617            covered[start..end].fill(true);
618        }
619
620        if covered.iter().any(|value| !*value) {
621            return Err(FormatError::InvalidArchive("decoded frames leave tar gap"));
622        }
623        if sha256_bytes(&tar_stream) != self.index_root.header.content_sha256 {
624            return Err(FormatError::InvalidArchive(
625                "IndexRoot content_sha256 does not match decoded tar stream",
626            ));
627        }
628
629        let mut file_extents = Vec::new();
630        let mut directory_hint_map = DirectoryHintMap::new();
631        for (shard_row_index, shard) in shards.iter().enumerate() {
632            let shard_row_index = u32::try_from(shard_row_index)
633                .map_err(|_| FormatError::InvalidArchive("shard row index overflow"))?;
634            for idx in 0..shard.files.len() {
635                let file = &shard.files[idx];
636                let start =
637                    shard
638                        .tar_member_group_start(idx)
639                        .ok_or(FormatError::InvalidArchive(
640                            "FileEntry tar member start is missing",
641                        ))?;
642                file_extents.push((start, file.tar_member_group_size));
643                let member = self.decode_loaded_owned_tar_member(shard, idx, false)?;
644                let path = shard
645                    .file_path(idx)
646                    .ok_or(FormatError::InvalidArchive("FileEntry path is missing"))?;
647                add_expected_directory_hint_rows(
648                    &mut directory_hint_map,
649                    shard_row_index,
650                    path,
651                    member.kind,
652                );
653            }
654        }
655        validate_file_extent_coverage_ranges(&file_extents, tar_len)?;
656        if !self.index_root.directory_hint_shards.is_empty() {
657            let hint_tables = self.load_all_directory_hint_tables()?;
658            validate_directory_hint_tables_against_expected(&hint_tables, &directory_hint_map)?;
659        }
660
661        Ok(())
662    }
663
664    fn load_all_index_shards(&self) -> Result<Vec<IndexShard>, FormatError> {
665        self.index_root
666            .shards
667            .iter()
668            .map(|entry| self.load_index_shard(entry))
669            .collect()
670    }
671
672    fn load_index_shard(&self, entry: &ShardEntry) -> Result<IndexShard, FormatError> {
673        let plaintext = load_metadata_object_from_parts(
674            &self.blocks,
675            &self.subkeys,
676            &self.volume_header,
677            &self.crypto_header,
678            ObjectExtent {
679                first_block_index: entry.first_block_index,
680                data_block_count: entry.data_block_count,
681                parity_block_count: entry.parity_block_count,
682                encrypted_size: entry.encrypted_size,
683            },
684            BlockKind::IndexShardData,
685            BlockKind::IndexShardParity,
686            &self.subkeys.index_shard_key,
687            &self.subkeys.index_nonce_seed,
688            b"idxshard",
689            entry.shard_index,
690            self.crypto_header.index_fec_data_shards,
691            self.crypto_header.index_fec_parity_shards,
692            entry.decompressed_size,
693        )?;
694        IndexShard::parse(&plaintext, entry, self.metadata_limits())
695    }
696
697    fn load_all_directory_hint_tables(&self) -> Result<Vec<DirectoryHintTable>, FormatError> {
698        self.index_root
699            .directory_hint_shards
700            .iter()
701            .map(|entry| self.load_directory_hint_table(entry))
702            .collect()
703    }
704
705    fn load_directory_hint_table(
706        &self,
707        entry: &DirectoryHintShardEntry,
708    ) -> Result<DirectoryHintTable, FormatError> {
709        let plaintext = load_metadata_object_from_parts(
710            &self.blocks,
711            &self.subkeys,
712            &self.volume_header,
713            &self.crypto_header,
714            ObjectExtent {
715                first_block_index: entry.first_block_index,
716                data_block_count: entry.data_block_count,
717                parity_block_count: entry.parity_block_count,
718                encrypted_size: entry.encrypted_size,
719            },
720            BlockKind::DirectoryHintData,
721            BlockKind::DirectoryHintParity,
722            &self.subkeys.dir_hint_key,
723            &self.subkeys.index_nonce_seed,
724            b"dirhint",
725            entry.hint_shard_index,
726            self.crypto_header.index_fec_data_shards,
727            self.crypto_header.index_fec_parity_shards,
728            entry.decompressed_size,
729        )?;
730        DirectoryHintTable::parse(
731            &plaintext,
732            entry,
733            self.index_root.header.shard_count,
734            self.metadata_limits(),
735        )
736    }
737
738    fn load_payload_envelope(&self, envelope: &EnvelopeEntry) -> Result<Vec<u8>, FormatError> {
739        let plaintext = load_decrypted_object_from_parts(
740            &self.blocks,
741            &self.volume_header,
742            &self.crypto_header,
743            ObjectExtent {
744                first_block_index: envelope.first_block_index,
745                data_block_count: envelope.data_block_count,
746                parity_block_count: envelope.parity_block_count,
747                encrypted_size: envelope.encrypted_size,
748            },
749            BlockKind::PayloadData,
750            BlockKind::PayloadParity,
751            &self.subkeys.enc_key,
752            &self.subkeys.nonce_seed,
753            b"envelope",
754            envelope.envelope_index,
755            self.crypto_header.fec_data_shards,
756            self.crypto_header.fec_parity_shards,
757        )?;
758        if plaintext.len() != envelope.plaintext_size as usize {
759            return Err(FormatError::InvalidArchive(
760                "payload envelope plaintext_size mismatch",
761            ));
762        }
763        Ok(plaintext)
764    }
765
766    fn extract_owned_tar_member(&self, path: &str) -> Result<Option<OwnedTarMember>, FormatError> {
767        let normalized = normalize_lookup_file_path(path, self.crypto_header.max_path_length)?;
768        let candidate_indexes = self
769            .index_root
770            .candidate_shards_for_path(&normalized, self.metadata_limits())?;
771        let mut winner: Option<(IndexShard, usize, u64)> = None;
772
773        for row_index in candidate_indexes {
774            let locating =
775                self.index_root
776                    .shards
777                    .get(row_index)
778                    .ok_or(FormatError::InvalidArchive(
779                        "candidate shard row is out of bounds",
780                    ))?;
781            let shard = self.load_index_shard(locating)?;
782            if let Some(file_index) = shard.lookup_file_index(&normalized) {
783                let start =
784                    shard
785                        .tar_member_group_start(file_index)
786                        .ok_or(FormatError::InvalidArchive(
787                            "FileEntry tar member start is missing",
788                        ))?;
789                if winner
790                    .as_ref()
791                    .map(|(_, _, best_start)| start > *best_start)
792                    .unwrap_or(true)
793                {
794                    winner = Some((shard, file_index, start));
795                }
796            }
797        }
798
799        winner
800            .map(|(shard, file_index, _)| self.extract_loaded_owned_tar_member(&shard, file_index))
801            .transpose()
802    }
803
804    fn extract_loaded_member(
805        &self,
806        shard: &IndexShard,
807        file_index: usize,
808    ) -> Result<ExtractedArchiveMember, FormatError> {
809        let member = self.extract_loaded_owned_tar_member(shard, file_index)?;
810        Ok(ExtractedArchiveMember {
811            path: utf8_path(&member.path)?,
812            kind: member.kind,
813            data: member.data,
814            link_target: member
815                .link_target
816                .map(|target| utf8_path(&target))
817                .transpose()?,
818            diagnostics: member.diagnostics,
819        })
820    }
821
822    fn extract_loaded_owned_tar_member(
823        &self,
824        shard: &IndexShard,
825        file_index: usize,
826    ) -> Result<OwnedTarMember, FormatError> {
827        self.decode_loaded_owned_tar_member(shard, file_index, true)
828    }
829
830    fn decode_loaded_owned_tar_member(
831        &self,
832        shard: &IndexShard,
833        file_index: usize,
834        enforce_extraction_cap: bool,
835    ) -> Result<OwnedTarMember, FormatError> {
836        let file = shard
837            .files
838            .get(file_index)
839            .ok_or(FormatError::InvalidArchive("FileEntry index out of bounds"))?;
840        if enforce_extraction_cap {
841            self.validate_total_extraction_size(file.file_data_size)?;
842        }
843        let expected_path = shard
844            .file_path(file_index)
845            .ok_or(FormatError::InvalidArchive("FileEntry path is missing"))?;
846        let frames = frame_range_for_file(shard, file)?;
847        let mut envelope_cache = HashMap::<u64, Vec<u8>>::new();
848        let mut decoded = Vec::new();
849
850        for frame in frames {
851            let envelope = shard
852                .envelopes
853                .iter()
854                .find(|entry| entry.envelope_index == frame.envelope_index)
855                .ok_or(FormatError::InvalidArchive(
856                    "FrameEntry references missing EnvelopeEntry",
857                ))?;
858            if !envelope_cache.contains_key(&envelope.envelope_index) {
859                envelope_cache.insert(
860                    envelope.envelope_index,
861                    self.load_payload_envelope(envelope)?,
862                );
863            }
864            let envelope_plaintext = envelope_cache
865                .get(&envelope.envelope_index)
866                .expect("inserted above");
867            let compressed = slice(
868                envelope_plaintext,
869                frame.offset_in_envelope as usize,
870                frame.compressed_size as usize,
871                "FrameEntry",
872            )?;
873            decoded.extend_from_slice(
874                &self.decompress_payload_frame(compressed, frame.decompressed_size)?,
875            );
876        }
877
878        let offset = file.offset_in_first_frame_plaintext as usize;
879        let group_len = to_usize(file.tar_member_group_size, "FileEntry")?;
880        let group = slice(&decoded, offset, group_len, "FileEntry")?;
881        let member = parse_tar_member_group(group, self.crypto_header.max_path_length)?;
882        if member.path != expected_path {
883            return Err(FormatError::InvalidArchive(
884                "tar member path does not match FileEntry path",
885            ));
886        }
887        if member.logical_size != file.file_data_size {
888            return Err(FormatError::InvalidArchive(
889                "tar member size does not match FileEntry file_data_size",
890            ));
891        }
892        Ok(member.to_owned_member())
893    }
894
895    fn metadata_limits(&self) -> MetadataLimits {
896        metadata_limits(&self.crypto_header)
897    }
898
899    fn validate_total_extraction_size(&self, logical_size: u64) -> Result<(), FormatError> {
900        let cap = total_extraction_size_cap(self.options, self.observed_archive_bytes);
901        if logical_size > cap {
902            return Err(FormatError::ReaderUnsupported(
903                "total extraction size exceeds configured cap",
904            ));
905        }
906        Ok(())
907    }
908
909    fn decompress_payload_frame(
910        &self,
911        compressed: &[u8],
912        decompressed_size: u32,
913    ) -> Result<Vec<u8>, FormatError> {
914        if let Some(dictionary) = &self.payload_dictionary {
915            decompress_exact_zstd_frame_with_dictionary(
916                compressed,
917                decompressed_size as usize,
918                dictionary,
919            )
920        } else {
921            decompress_exact_zstd_frame(compressed, decompressed_size as usize)
922        }
923    }
924
925    fn validate_encrypted_object_block_ranges(
926        &self,
927        envelopes: &BTreeMap<u64, EnvelopeEntry>,
928    ) -> Result<(), FormatError> {
929        let mut ranges = Vec::new();
930        ranges.push(object_block_range(
931            self.manifest_footer.index_root_first_block,
932            self.manifest_footer.index_root_data_block_count,
933            self.manifest_footer.index_root_parity_block_count,
934            "IndexRoot",
935        )?);
936        for shard in &self.index_root.shards {
937            ranges.push(object_block_range(
938                shard.first_block_index,
939                shard.data_block_count,
940                shard.parity_block_count,
941                "IndexShard",
942            )?);
943        }
944        for hint in &self.index_root.directory_hint_shards {
945            ranges.push(object_block_range(
946                hint.first_block_index,
947                hint.data_block_count,
948                hint.parity_block_count,
949                "DirectoryHintShardEntry",
950            )?);
951        }
952        if self.crypto_header.has_dictionary != 0 {
953            ranges.push(object_block_range(
954                self.index_root.header.dictionary_first_block,
955                self.index_root.header.dictionary_data_block_count,
956                self.index_root.header.dictionary_parity_block_count,
957                "dictionary",
958            )?);
959        }
960        for envelope in envelopes.values() {
961            ranges.push(object_block_range(
962                envelope.first_block_index,
963                envelope.data_block_count,
964                envelope.parity_block_count,
965                "EnvelopeEntry",
966            )?);
967        }
968        validate_non_overlapping_object_ranges(&mut ranges)
969    }
970}
971
972#[derive(Debug)]
973struct ParsedSeekableVolume {
974    volume_header: VolumeHeader,
975    crypto_header: CryptoHeaderFixed,
976    crypto_header_bytes: Vec<u8>,
977    subkeys: Subkeys,
978    manifest_footer: ManifestFooter,
979    volume_trailer: VolumeTrailer,
980    blocks: BTreeMap<u64, BlockRecord>,
981    erased_block_indices: BTreeSet<u64>,
982}
983
984fn parse_seekable_volume(
985    bytes: &[u8],
986    master_key: &MasterKey,
987    options: ReaderOptions,
988) -> Result<ParsedSeekableVolume, FormatError> {
989    if bytes.len() < VOLUME_HEADER_LEN + VOLUME_TRAILER_LEN {
990        return Err(FormatError::InvalidLength {
991            structure: "archive",
992            expected: VOLUME_HEADER_LEN + VOLUME_TRAILER_LEN,
993            actual: bytes.len(),
994        });
995    }
996
997    let volume_header = VolumeHeader::parse(slice(bytes, 0, VOLUME_HEADER_LEN, "archive")?)?;
998    let crypto_start = volume_header.crypto_header_offset as usize;
999    let crypto_len = volume_header.crypto_header_length as usize;
1000    let crypto_end = checked_add(crypto_start, crypto_len, "CryptoHeader")?;
1001    let crypto_bytes = slice(bytes, crypto_start, crypto_len, "CryptoHeader")?;
1002    let parsed_crypto = CryptoHeader::parse(crypto_bytes, volume_header.crypto_header_length)?;
1003    let subkeys = Subkeys::derive(
1004        master_key,
1005        &volume_header.archive_uuid,
1006        &volume_header.session_id,
1007    )?;
1008    verify_hmac(
1009        HmacDomain::CryptoHeader,
1010        &subkeys.mac_key,
1011        &volume_header.archive_uuid,
1012        &volume_header.session_id,
1013        parsed_crypto.hmac_covered_bytes,
1014        &parsed_crypto.header_hmac,
1015    )?;
1016    parsed_crypto.validate_extension_semantics()?;
1017    validate_seekable_supported_volume(&volume_header, &parsed_crypto.fixed)?;
1018
1019    let (trailer_offset, volume_trailer) =
1020        locate_trailer(bytes, &subkeys, &volume_header, options)?;
1021    validate_trailer_identity(&volume_header, &volume_trailer)?;
1022
1023    let manifest_offset = to_usize(volume_trailer.manifest_footer_offset, "ManifestFooter")?;
1024    let manifest_end = checked_add(manifest_offset, MANIFEST_FOOTER_LEN, "ManifestFooter")?;
1025    if manifest_end != trailer_offset {
1026        return Err(FormatError::InvalidArchive(
1027            "ManifestFooter does not end at selected trailer",
1028        ));
1029    }
1030    let manifest_bytes = slice(
1031        bytes,
1032        manifest_offset,
1033        MANIFEST_FOOTER_LEN,
1034        "ManifestFooter",
1035    )?;
1036    let manifest_footer = ManifestFooter::parse(manifest_bytes)?;
1037    validate_manifest_footer(&volume_header, &manifest_footer, &subkeys, manifest_bytes)?;
1038    manifest_footer.validate_index_root_extent(parsed_crypto.fixed.block_size)?;
1039
1040    let block_region = parse_block_region(
1041        bytes,
1042        crypto_end,
1043        manifest_offset,
1044        parsed_crypto.fixed.block_size as usize,
1045        &volume_header,
1046        &volume_trailer,
1047    )?;
1048
1049    Ok(ParsedSeekableVolume {
1050        volume_header,
1051        crypto_header: parsed_crypto.fixed,
1052        crypto_header_bytes: crypto_bytes.to_vec(),
1053        subkeys,
1054        manifest_footer,
1055        volume_trailer,
1056        blocks: block_region.blocks,
1057        erased_block_indices: block_region.erased_block_indices,
1058    })
1059}
1060
1061fn validate_seekable_supported_volume(
1062    volume_header: &VolumeHeader,
1063    crypto_header: &CryptoHeaderFixed,
1064) -> Result<(), FormatError> {
1065    if crypto_header.stripe_width != volume_header.stripe_width {
1066        return Err(FormatError::InvalidArchive(
1067            "VolumeHeader and CryptoHeader stripe_width differ",
1068        ));
1069    }
1070    Ok(())
1071}
1072
1073fn validate_volume_set_member(
1074    first: &ParsedSeekableVolume,
1075    candidate: &ParsedSeekableVolume,
1076) -> Result<(), FormatError> {
1077    if candidate.volume_header.archive_uuid != first.volume_header.archive_uuid
1078        || candidate.volume_header.session_id != first.volume_header.session_id
1079    {
1080        return Err(FormatError::InvalidArchive(
1081            "mixed archive or session IDs in volume set",
1082        ));
1083    }
1084    if candidate.crypto_header_bytes != first.crypto_header_bytes
1085        || candidate.crypto_header != first.crypto_header
1086    {
1087        return Err(FormatError::InvalidArchive("CryptoHeader copies differ"));
1088    }
1089    if !manifest_bootstrap_fields_match(&first.manifest_footer, &candidate.manifest_footer) {
1090        return Err(FormatError::InvalidArchive(
1091            "ManifestFooter bootstrap fields differ",
1092        ));
1093    }
1094    Ok(())
1095}
1096
1097fn manifest_bootstrap_fields_match(left: &ManifestFooter, right: &ManifestFooter) -> bool {
1098    left.archive_uuid == right.archive_uuid
1099        && left.session_id == right.session_id
1100        && left.is_authoritative == right.is_authoritative
1101        && left.total_volumes == right.total_volumes
1102        && left.index_root_first_block == right.index_root_first_block
1103        && left.index_root_data_block_count == right.index_root_data_block_count
1104        && left.index_root_parity_block_count == right.index_root_parity_block_count
1105        && left.index_root_encrypted_size == right.index_root_encrypted_size
1106        && left.index_root_decompressed_size == right.index_root_decompressed_size
1107}
1108
1109fn validate_complete_global_block_coverage(
1110    blocks: &BTreeMap<u64, BlockRecord>,
1111    erased_block_indices: &BTreeSet<u64>,
1112) -> Result<(), FormatError> {
1113    let mut expected = 0u64;
1114    let mut block_iter = blocks.keys().copied().peekable();
1115    let mut erasure_iter = erased_block_indices.iter().copied().peekable();
1116
1117    loop {
1118        let next_block = block_iter.peek().copied();
1119        let next_erasure = erasure_iter.peek().copied();
1120        let next = match (next_block, next_erasure) {
1121            (Some(block), Some(erasure)) if block == erasure => {
1122                return Err(FormatError::InvalidArchive(
1123                    "BlockRecord index is both present and erased",
1124                ));
1125            }
1126            (Some(block), Some(erasure)) => block.min(erasure),
1127            (Some(block), None) => block,
1128            (None, Some(erasure)) => erasure,
1129            (None, None) => return Ok(()),
1130        };
1131
1132        if next != expected {
1133            return Err(FormatError::InvalidArchive(
1134                "complete volume set has missing global blocks",
1135            ));
1136        }
1137        if next_block == Some(next) {
1138            block_iter.next();
1139        }
1140        if next_erasure == Some(next) {
1141            erasure_iter.next();
1142        }
1143        expected = expected
1144            .checked_add(1)
1145            .ok_or(FormatError::InvalidArchive("global block index overflow"))?;
1146    }
1147}
1148
1149fn locate_trailer(
1150    bytes: &[u8],
1151    subkeys: &Subkeys,
1152    volume_header: &VolumeHeader,
1153    options: ReaderOptions,
1154) -> Result<(usize, VolumeTrailer), FormatError> {
1155    let canonical_offset =
1156        bytes
1157            .len()
1158            .checked_sub(VOLUME_TRAILER_LEN)
1159            .ok_or(FormatError::InvalidLength {
1160                structure: "VolumeTrailer",
1161                expected: VOLUME_TRAILER_LEN,
1162                actual: bytes.len(),
1163            })?;
1164    match parse_authenticated_trailer(bytes, canonical_offset, subkeys, volume_header) {
1165        Ok(trailer) => {
1166            if trailer.bytes_written != canonical_offset as u64 {
1167                return Err(FormatError::InvalidArchive(
1168                    "VolumeTrailer bytes_written does not match selected trailer offset",
1169                ));
1170            }
1171            return Ok((canonical_offset, trailer));
1172        }
1173        Err(err) if options.max_trailing_garbage_scan == 0 => return Err(err),
1174        Err(_) => {}
1175    }
1176
1177    let scan_start = canonical_offset.saturating_sub(options.max_trailing_garbage_scan);
1178    for offset in (scan_start..canonical_offset).rev() {
1179        if let Ok(trailer) = parse_authenticated_trailer(bytes, offset, subkeys, volume_header) {
1180            if trailer.bytes_written == offset as u64 {
1181                return Ok((offset, trailer));
1182            }
1183        }
1184    }
1185
1186    Err(FormatError::InvalidArchive(
1187        "no authenticated VolumeTrailer found",
1188    ))
1189}
1190
1191fn parse_authenticated_trailer(
1192    bytes: &[u8],
1193    offset: usize,
1194    subkeys: &Subkeys,
1195    volume_header: &VolumeHeader,
1196) -> Result<VolumeTrailer, FormatError> {
1197    let raw = slice(bytes, offset, VOLUME_TRAILER_LEN, "VolumeTrailer")?;
1198    let trailer = VolumeTrailer::parse(raw)?;
1199    verify_hmac(
1200        HmacDomain::VolumeTrailer,
1201        &subkeys.mac_key,
1202        &volume_header.archive_uuid,
1203        &volume_header.session_id,
1204        &raw[..TRAILER_HMAC_COVERED_LEN],
1205        &trailer.trailer_hmac,
1206    )?;
1207    Ok(trailer)
1208}
1209
1210fn validate_m9_supported_volume(
1211    volume_header: &VolumeHeader,
1212    crypto_header: &CryptoHeaderFixed,
1213) -> Result<(), FormatError> {
1214    if volume_header.stripe_width != 1 || volume_header.volume_index != 0 {
1215        return Err(FormatError::ReaderUnsupported(
1216            "M9 reader supports only single-volume archives",
1217        ));
1218    }
1219    if crypto_header.stripe_width != volume_header.stripe_width {
1220        return Err(FormatError::InvalidArchive(
1221            "VolumeHeader and CryptoHeader stripe_width differ",
1222        ));
1223    }
1224    Ok(())
1225}
1226
1227#[derive(Debug)]
1228struct TrustedBootstrapSidecar {
1229    manifest_footer: ManifestFooter,
1230    index_root_records: Vec<BlockRecord>,
1231    dictionary_records_section: Option<(u64, u64)>,
1232}
1233
1234fn parse_trusted_bootstrap_sidecar(
1235    bytes: &[u8],
1236    volume_header: &VolumeHeader,
1237    crypto_header: &CryptoHeaderFixed,
1238    subkeys: &Subkeys,
1239) -> Result<TrustedBootstrapSidecar, FormatError> {
1240    let header_bytes = slice(
1241        bytes,
1242        0,
1243        BOOTSTRAP_SIDECAR_HEADER_LEN,
1244        "BootstrapSidecarHeader",
1245    )?;
1246    let header = BootstrapSidecarHeader::parse(header_bytes)?;
1247    if header.archive_uuid != volume_header.archive_uuid
1248        || header.session_id != volume_header.session_id
1249    {
1250        return Err(FormatError::InvalidArchive(
1251            "bootstrap sidecar identity does not match VolumeHeader",
1252        ));
1253    }
1254    verify_hmac(
1255        HmacDomain::BootstrapSidecar,
1256        &subkeys.mac_key,
1257        &volume_header.archive_uuid,
1258        &volume_header.session_id,
1259        &header_bytes[..SIDECAR_HMAC_COVERED_LEN],
1260        &header.sidecar_hmac,
1261    )?;
1262    header.validate_packed_layout(bytes.len() as u64)?;
1263    validate_sidecar_size_cap(&header, crypto_header, bytes.len() as u64)?;
1264
1265    if !header.has_manifest_footer() || !header.has_index_root_records() {
1266        return Err(FormatError::ReaderUnsupported(
1267            "non-seekable bootstrap sidecar requires ManifestFooter and IndexRoot sections",
1268        ));
1269    }
1270    if header.has_dictionary_records() {
1271        if crypto_header.has_dictionary == 0 {
1272            return Err(FormatError::InvalidArchive(
1273                "bootstrap sidecar has dictionary records while has_dictionary is false",
1274            ));
1275        }
1276    } else if crypto_header.has_dictionary != 0 {
1277        return Err(FormatError::ReaderUnsupported(
1278            "dictionary bootstrap required",
1279        ));
1280    }
1281
1282    let manifest_offset = to_usize(header.manifest_footer_offset, "BootstrapSidecarHeader")?;
1283    let manifest_bytes = slice(
1284        bytes,
1285        manifest_offset,
1286        MANIFEST_FOOTER_LEN,
1287        "ManifestFooter",
1288    )?;
1289    let manifest_footer = ManifestFooter::parse(manifest_bytes)?;
1290    validate_sidecar_manifest_footer(
1291        volume_header,
1292        crypto_header,
1293        &manifest_footer,
1294        subkeys,
1295        manifest_bytes,
1296    )?;
1297    manifest_footer.validate_index_root_extent(crypto_header.block_size)?;
1298
1299    let index_root_records = parse_sidecar_block_records(
1300        bytes,
1301        header.index_root_records_offset,
1302        header.index_root_records_length,
1303        crypto_header.block_size as usize,
1304        ObjectExtent {
1305            first_block_index: manifest_footer.index_root_first_block,
1306            data_block_count: manifest_footer.index_root_data_block_count,
1307            parity_block_count: manifest_footer.index_root_parity_block_count,
1308            encrypted_size: manifest_footer.index_root_encrypted_size,
1309        },
1310        BlockKind::IndexRootData,
1311        BlockKind::IndexRootParity,
1312        "IndexRoot",
1313    )?;
1314
1315    Ok(TrustedBootstrapSidecar {
1316        manifest_footer,
1317        index_root_records,
1318        dictionary_records_section: header.has_dictionary_records().then_some((
1319            header.dictionary_records_offset,
1320            header.dictionary_records_length,
1321        )),
1322    })
1323}
1324
1325fn insert_sidecar_records(
1326    blocks: &mut BTreeMap<u64, BlockRecord>,
1327    records: Vec<BlockRecord>,
1328) -> Result<(), FormatError> {
1329    for record in records {
1330        if let Some(existing) = blocks.insert(record.block_index, record.clone()) {
1331            if existing != record {
1332                return Err(FormatError::InvalidArchive(
1333                    "bootstrap sidecar conflicts with volume BlockRecord",
1334                ));
1335            }
1336        }
1337    }
1338    Ok(())
1339}
1340
1341fn validate_sidecar_manifest_footer(
1342    volume_header: &VolumeHeader,
1343    crypto_header: &CryptoHeaderFixed,
1344    footer: &ManifestFooter,
1345    subkeys: &Subkeys,
1346    raw: &[u8],
1347) -> Result<(), FormatError> {
1348    if footer.archive_uuid != volume_header.archive_uuid
1349        || footer.session_id != volume_header.session_id
1350    {
1351        return Err(FormatError::InvalidArchive(
1352            "sidecar ManifestFooter identity does not match VolumeHeader",
1353        ));
1354    }
1355    if footer.volume_index != 0 {
1356        return Err(FormatError::InvalidArchive(
1357            "sidecar ManifestFooter volume_index must be zero",
1358        ));
1359    }
1360    if footer.total_volumes != crypto_header.stripe_width {
1361        return Err(FormatError::InvalidArchive(
1362            "sidecar ManifestFooter total_volumes does not match stripe_width",
1363        ));
1364    }
1365    if footer.is_authoritative != 1 {
1366        return Err(FormatError::InvalidArchive(
1367            "sidecar ManifestFooter is not authoritative",
1368        ));
1369    }
1370    verify_hmac(
1371        HmacDomain::ManifestFooter,
1372        &subkeys.mac_key,
1373        &volume_header.archive_uuid,
1374        &volume_header.session_id,
1375        &raw[..MANIFEST_HMAC_COVERED_LEN],
1376        &footer.manifest_hmac,
1377    )
1378}
1379
1380fn validate_sidecar_size_cap(
1381    header: &BootstrapSidecarHeader,
1382    crypto_header: &CryptoHeaderFixed,
1383    file_size: u64,
1384) -> Result<(), FormatError> {
1385    let record_len = checked_u64_add(
1386        crypto_header.block_size as u64,
1387        BLOCK_RECORD_FRAMING_LEN as u64,
1388        "bootstrap sidecar cap overflow",
1389    )?;
1390    let max_index_records = crypto_header.index_root_fec_data_shards as u64
1391        + crypto_header.index_root_fec_parity_shards as u64;
1392    let max_record_section_bytes = checked_u64_mul(
1393        max_index_records,
1394        record_len,
1395        "bootstrap sidecar cap overflow",
1396    )?;
1397    if header.index_root_records_length % record_len != 0 {
1398        return Err(FormatError::InvalidArchive(
1399            "bootstrap sidecar IndexRoot records length is not aligned",
1400        ));
1401    }
1402    if header.index_root_records_length / record_len > max_index_records {
1403        return Err(FormatError::InvalidArchive(
1404            "bootstrap sidecar IndexRoot records exceed resource cap",
1405        ));
1406    }
1407    if header.dictionary_records_length % record_len != 0 {
1408        return Err(FormatError::InvalidArchive(
1409            "bootstrap sidecar dictionary records length is not aligned",
1410        ));
1411    }
1412    if header.dictionary_records_length / record_len > max_index_records {
1413        return Err(FormatError::InvalidArchive(
1414            "bootstrap sidecar dictionary records exceed resource cap",
1415        ));
1416    }
1417
1418    let mut cap = BOOTSTRAP_SIDECAR_HEADER_LEN as u64;
1419    if header.has_manifest_footer() {
1420        cap = cap
1421            .checked_add(MANIFEST_FOOTER_LEN as u64)
1422            .ok_or(FormatError::InvalidArchive(
1423                "bootstrap sidecar cap overflow",
1424            ))?;
1425    }
1426    if header.has_index_root_records() {
1427        cap = checked_u64_add(
1428            cap,
1429            max_record_section_bytes,
1430            "bootstrap sidecar cap overflow",
1431        )?;
1432    }
1433    if header.has_dictionary_records() {
1434        cap = checked_u64_add(
1435            cap,
1436            max_record_section_bytes,
1437            "bootstrap sidecar cap overflow",
1438        )?;
1439    }
1440    if file_size > cap {
1441        return Err(FormatError::InvalidArchive(
1442            "bootstrap sidecar exceeds resource cap",
1443        ));
1444    }
1445    Ok(())
1446}
1447
1448fn parse_sidecar_block_records(
1449    sidecar_bytes: &[u8],
1450    offset: u64,
1451    length: u64,
1452    block_size: usize,
1453    extent: ObjectExtent,
1454    data_kind: BlockKind,
1455    parity_kind: BlockKind,
1456    structure: &'static str,
1457) -> Result<Vec<BlockRecord>, FormatError> {
1458    let record_len = block_size
1459        .checked_add(BLOCK_RECORD_FRAMING_LEN)
1460        .ok_or(FormatError::InvalidArchive("BlockRecord length overflow"))?;
1461    if length % record_len as u64 != 0 {
1462        return Err(FormatError::InvalidArchive(
1463            "sidecar BlockRecord section is not aligned",
1464        ));
1465    }
1466    let expected_count = extent.data_block_count as usize + extent.parity_block_count as usize;
1467    let actual_count = usize::try_from(length / record_len as u64)
1468        .map_err(|_| FormatError::InvalidArchive("sidecar BlockRecord count overflow"))?;
1469    if actual_count != expected_count {
1470        return Err(FormatError::InvalidArchive(
1471            "sidecar BlockRecord section does not match declared extent",
1472        ));
1473    }
1474    let start = to_usize(offset, "BootstrapSidecarHeader")?;
1475    let raw = slice(
1476        sidecar_bytes,
1477        start,
1478        to_usize(length, "BootstrapSidecarHeader")?,
1479        "BootstrapSidecarHeader",
1480    )?;
1481    let mut records = Vec::with_capacity(expected_count);
1482
1483    for idx in 0..expected_count {
1484        let record = BlockRecord::parse(
1485            slice(raw, idx * record_len, record_len, "BlockRecord")?,
1486            block_size,
1487        )?;
1488        let expected_block_index =
1489            checked_u64_add(extent.first_block_index, idx as u64, structure)?;
1490        if record.block_index != expected_block_index {
1491            return Err(FormatError::InvalidArchive(
1492                "sidecar BlockRecord section has missing or duplicate blocks",
1493            ));
1494        }
1495        let expected_kind = if idx < extent.data_block_count as usize {
1496            data_kind
1497        } else {
1498            parity_kind
1499        };
1500        if record.kind != expected_kind {
1501            return Err(FormatError::InvalidArchive(
1502                "sidecar BlockRecord section has wrong kind",
1503            ));
1504        }
1505        let should_be_last = idx + 1 == extent.data_block_count as usize;
1506        if idx < extent.data_block_count as usize && record.is_last_data() != should_be_last {
1507            return Err(FormatError::InvalidArchive(
1508                "sidecar BlockRecord section has wrong last-data flag",
1509            ));
1510        }
1511        records.push(record);
1512    }
1513
1514    Ok(records)
1515}
1516
1517fn validate_trailer_identity(
1518    volume_header: &VolumeHeader,
1519    trailer: &VolumeTrailer,
1520) -> Result<(), FormatError> {
1521    if trailer.archive_uuid != volume_header.archive_uuid
1522        || trailer.session_id != volume_header.session_id
1523        || trailer.volume_index != volume_header.volume_index
1524    {
1525        return Err(FormatError::InvalidArchive(
1526            "VolumeTrailer identity does not match VolumeHeader",
1527        ));
1528    }
1529    Ok(())
1530}
1531
1532fn validate_manifest_footer(
1533    volume_header: &VolumeHeader,
1534    footer: &ManifestFooter,
1535    subkeys: &Subkeys,
1536    raw: &[u8],
1537) -> Result<(), FormatError> {
1538    if footer.archive_uuid != volume_header.archive_uuid
1539        || footer.session_id != volume_header.session_id
1540        || footer.volume_index != volume_header.volume_index
1541    {
1542        return Err(FormatError::InvalidArchive(
1543            "ManifestFooter identity does not match VolumeHeader",
1544        ));
1545    }
1546    if footer.total_volumes != volume_header.stripe_width {
1547        return Err(FormatError::InvalidArchive(
1548            "ManifestFooter total_volumes does not match stripe_width",
1549        ));
1550    }
1551    if footer.is_authoritative != 1 {
1552        return Err(FormatError::InvalidArchive(
1553            "ManifestFooter is not authoritative",
1554        ));
1555    }
1556    verify_hmac(
1557        HmacDomain::ManifestFooter,
1558        &subkeys.mac_key,
1559        &volume_header.archive_uuid,
1560        &volume_header.session_id,
1561        &raw[..MANIFEST_HMAC_COVERED_LEN],
1562        &footer.manifest_hmac,
1563    )
1564}
1565
1566#[derive(Debug)]
1567struct ParsedBlockRegion {
1568    blocks: BTreeMap<u64, BlockRecord>,
1569    erased_block_indices: BTreeSet<u64>,
1570}
1571
1572fn parse_block_region(
1573    bytes: &[u8],
1574    start: usize,
1575    end: usize,
1576    block_size: usize,
1577    volume_header: &VolumeHeader,
1578    trailer: &VolumeTrailer,
1579) -> Result<ParsedBlockRegion, FormatError> {
1580    if end < start {
1581        return Err(FormatError::InvalidArchive(
1582            "ManifestFooter starts before BlockRecord region",
1583        ));
1584    }
1585    let record_len = block_size
1586        .checked_add(BLOCK_RECORD_FRAMING_LEN)
1587        .ok_or(FormatError::InvalidArchive("BlockRecord length overflow"))?;
1588    let region_len = end - start;
1589    if region_len % record_len != 0 {
1590        return Err(FormatError::InvalidArchive(
1591            "BlockRecord region length is not aligned",
1592        ));
1593    }
1594    let observed_count = region_len / record_len;
1595    if observed_count as u64 != trailer.block_count {
1596        return Err(FormatError::InvalidArchive(
1597            "VolumeTrailer block_count does not match BlockRecord region",
1598        ));
1599    }
1600
1601    let mut blocks = BTreeMap::new();
1602    let mut erased_block_indices = BTreeSet::new();
1603    for idx in 0..observed_count {
1604        let offset = start + idx * record_len;
1605        let expected_block_index = checked_u64_add(
1606            volume_header.volume_index as u64,
1607            checked_u64_mul(
1608                idx as u64,
1609                volume_header.stripe_width as u64,
1610                "BlockRecord index overflow",
1611            )?,
1612            "BlockRecord index overflow",
1613        )?;
1614        let raw = slice(bytes, offset, record_len, "BlockRecord")?;
1615        match BlockRecord::parse(raw, block_size) {
1616            Ok(record) => {
1617                if record.block_index != expected_block_index {
1618                    return Err(FormatError::InvalidArchive(
1619                        "BlockRecord index does not match volume position",
1620                    ));
1621                }
1622                if blocks.insert(record.block_index, record).is_some() {
1623                    return Err(FormatError::InvalidArchive("duplicate BlockRecord index"));
1624                }
1625            }
1626            Err(err) if block_record_error_is_recoverable_erasure(&err) => {
1627                if !erased_block_indices.insert(expected_block_index) {
1628                    return Err(FormatError::InvalidArchive(
1629                        "duplicate erased BlockRecord index",
1630                    ));
1631                }
1632            }
1633            Err(err) => return Err(err),
1634        }
1635    }
1636
1637    Ok(ParsedBlockRegion {
1638        blocks,
1639        erased_block_indices,
1640    })
1641}
1642
1643fn block_record_error_is_recoverable_erasure(error: &FormatError) -> bool {
1644    match error {
1645        FormatError::BadCrc { structure }
1646        | FormatError::BadMagic { structure }
1647        | FormatError::NonZeroReserved { structure } => *structure == "BlockRecord",
1648        _ => false,
1649    }
1650}
1651
1652fn checked_u64_mul(lhs: u64, rhs: u64, reason: &'static str) -> Result<u64, FormatError> {
1653    lhs.checked_mul(rhs)
1654        .ok_or(FormatError::InvalidArchive(reason))
1655}
1656
1657fn parse_stream_block_prefix(
1658    bytes: &[u8],
1659    start: usize,
1660    block_size: usize,
1661    volume_header: &VolumeHeader,
1662) -> Result<(BTreeMap<u64, BlockRecord>, usize, u64), FormatError> {
1663    let record_len = block_size
1664        .checked_add(BLOCK_RECORD_FRAMING_LEN)
1665        .ok_or(FormatError::InvalidArchive("BlockRecord length overflow"))?;
1666    let mut blocks = BTreeMap::new();
1667    let mut offset = start;
1668    let mut observed_block_count = 0u64;
1669
1670    while bytes.get(offset..offset + 4) == Some(b"TZBK") {
1671        let expected_block_index =
1672            expected_stream_block_index(volume_header, observed_block_count)?;
1673        let raw = slice(bytes, offset, record_len, "BlockRecord")?;
1674        match BlockRecord::parse(raw, block_size) {
1675            Ok(record) => {
1676                if record.block_index != expected_block_index {
1677                    return Err(FormatError::InvalidArchive(
1678                        "BlockRecord index does not match stream position",
1679                    ));
1680                }
1681                if blocks.insert(record.block_index, record).is_some() {
1682                    return Err(FormatError::InvalidArchive("duplicate BlockRecord index"));
1683                }
1684            }
1685            Err(err) if block_record_error_is_recoverable_erasure(&err) => {}
1686            Err(err) => return Err(err),
1687        }
1688        offset = checked_add(offset, record_len, "BlockRecord")?;
1689        observed_block_count = observed_block_count
1690            .checked_add(1)
1691            .ok_or(FormatError::InvalidArchive("BlockRecord count overflow"))?;
1692    }
1693
1694    Ok((blocks, offset, observed_block_count))
1695}
1696
1697fn expected_stream_block_index(
1698    volume_header: &VolumeHeader,
1699    observed_block_count: u64,
1700) -> Result<u64, FormatError> {
1701    checked_u64_add(
1702        volume_header.volume_index as u64,
1703        checked_u64_mul(
1704            observed_block_count,
1705            volume_header.stripe_width as u64,
1706            "BlockRecord index overflow",
1707        )?,
1708        "BlockRecord index overflow",
1709    )
1710}
1711
1712fn parse_sequential_block_or_erasure(
1713    bytes: &[u8],
1714    offset: usize,
1715    record_len: usize,
1716    block_size: usize,
1717    volume_header: &VolumeHeader,
1718    observed_block_count: u64,
1719) -> Result<Option<BlockRecord>, FormatError> {
1720    let expected_block_index = expected_stream_block_index(volume_header, observed_block_count)?;
1721    let raw = slice(bytes, offset, record_len, "BlockRecord")?;
1722    match BlockRecord::parse(raw, block_size) {
1723        Ok(record) => {
1724            if record.block_index != expected_block_index {
1725                return Err(FormatError::InvalidArchive(
1726                    "BlockRecord index does not match stream position",
1727                ));
1728            }
1729            Ok(Some(record))
1730        }
1731        Err(err) if block_record_error_is_recoverable_erasure(&err) => Ok(None),
1732        Err(err) => Err(err),
1733    }
1734}
1735
1736fn parse_terminal_material(
1737    bytes: &[u8],
1738    manifest_offset: usize,
1739    observed_block_count: u64,
1740    subkeys: &Subkeys,
1741    volume_header: &VolumeHeader,
1742    block_size: u32,
1743) -> Result<(ManifestFooter, VolumeTrailer), FormatError> {
1744    let manifest_end = checked_add(manifest_offset, MANIFEST_FOOTER_LEN, "ManifestFooter")?;
1745    let trailer_end = checked_add(manifest_end, VOLUME_TRAILER_LEN, "VolumeTrailer")?;
1746    if trailer_end != bytes.len() {
1747        return Err(FormatError::InvalidArchive(
1748            "terminal ManifestFooter/VolumeTrailer is not packed at stream end",
1749        ));
1750    }
1751
1752    let manifest_bytes = slice(
1753        bytes,
1754        manifest_offset,
1755        MANIFEST_FOOTER_LEN,
1756        "ManifestFooter",
1757    )?;
1758    let manifest_footer = ManifestFooter::parse(manifest_bytes)?;
1759    validate_manifest_footer(volume_header, &manifest_footer, subkeys, manifest_bytes)?;
1760    manifest_footer.validate_index_root_extent(block_size)?;
1761
1762    let trailer = parse_authenticated_trailer(bytes, manifest_end, subkeys, volume_header)?;
1763    validate_trailer_identity(volume_header, &trailer)?;
1764    if trailer.manifest_footer_offset != manifest_offset as u64 {
1765        return Err(FormatError::InvalidArchive(
1766            "VolumeTrailer ManifestFooter offset does not match observed stream offset",
1767        ));
1768    }
1769    if trailer.manifest_footer_length != MANIFEST_FOOTER_LEN as u32 {
1770        return Err(FormatError::InvalidManifestFooterLength(
1771            trailer.manifest_footer_length,
1772        ));
1773    }
1774    if trailer.bytes_written != manifest_end as u64 {
1775        return Err(FormatError::InvalidArchive(
1776            "VolumeTrailer bytes_written does not match observed trailer offset",
1777        ));
1778    }
1779    if trailer.block_count != observed_block_count {
1780        return Err(FormatError::InvalidArchive(
1781            "VolumeTrailer block_count does not match observed stream",
1782        ));
1783    }
1784
1785    Ok((manifest_footer, trailer))
1786}
1787
1788#[derive(Debug, Default)]
1789struct PendingSequentialEnvelope {
1790    data_shards: Vec<Option<Vec<u8>>>,
1791    parity_shards: Vec<Option<Vec<u8>>>,
1792    saw_last_data: bool,
1793    awaiting_tentative_parity: bool,
1794}
1795
1796impl PendingSequentialEnvelope {
1797    fn is_empty(&self) -> bool {
1798        self.data_shards.is_empty() && self.parity_shards.is_empty()
1799    }
1800}
1801
1802fn handle_sequential_payload_erasure(
1803    pending: &mut PendingSequentialEnvelope,
1804    crypto_header: &CryptoHeaderFixed,
1805    metadata_seen: bool,
1806) -> Result<(), FormatError> {
1807    if metadata_seen || pending.saw_last_data {
1808        return Err(FormatError::BadCrc {
1809            structure: "BlockRecord",
1810        });
1811    }
1812    if !sequential_payload_parity_is_guaranteed(crypto_header) {
1813        return Err(FormatError::BadCrc {
1814            structure: "BlockRecord",
1815        });
1816    }
1817    pending.data_shards.push(None);
1818    pending.awaiting_tentative_parity = true;
1819    if pending.data_shards.len() > crypto_header.fec_data_shards as usize {
1820        return Err(FormatError::InvalidArchive(
1821            "sequential payload envelope exceeds data-shard cap",
1822        ));
1823    }
1824    Ok(())
1825}
1826
1827fn sequential_payload_parity_is_guaranteed(crypto_header: &CryptoHeaderFixed) -> bool {
1828    crypto_header.fec_parity_shards > 0
1829        && (crypto_header.volume_loss_tolerance > 0 || crypto_header.bit_rot_buffer_pct > 0)
1830}
1831
1832fn sequential_extract_tar_stream_with_options(
1833    bytes: &[u8],
1834    master_key: &MasterKey,
1835    options: ReaderOptions,
1836) -> Result<Vec<u8>, FormatError> {
1837    if bytes.len() < VOLUME_HEADER_LEN {
1838        return Err(FormatError::InvalidLength {
1839            structure: "archive",
1840            expected: VOLUME_HEADER_LEN,
1841            actual: bytes.len(),
1842        });
1843    }
1844
1845    let volume_header = VolumeHeader::parse(slice(bytes, 0, VOLUME_HEADER_LEN, "archive")?)?;
1846    let crypto_start = volume_header.crypto_header_offset as usize;
1847    let crypto_len = volume_header.crypto_header_length as usize;
1848    let crypto_end = checked_add(crypto_start, crypto_len, "CryptoHeader")?;
1849    let crypto_bytes = slice(bytes, crypto_start, crypto_len, "CryptoHeader")?;
1850    let parsed_crypto = CryptoHeader::parse(crypto_bytes, volume_header.crypto_header_length)?;
1851    let subkeys = Subkeys::derive(
1852        master_key,
1853        &volume_header.archive_uuid,
1854        &volume_header.session_id,
1855    )?;
1856    verify_hmac(
1857        HmacDomain::CryptoHeader,
1858        &subkeys.mac_key,
1859        &volume_header.archive_uuid,
1860        &volume_header.session_id,
1861        parsed_crypto.hmac_covered_bytes,
1862        &parsed_crypto.header_hmac,
1863    )?;
1864    parsed_crypto.validate_extension_semantics()?;
1865    validate_sequential_supported_volume(&volume_header, &parsed_crypto.fixed)?;
1866
1867    let block_size = parsed_crypto.fixed.block_size as usize;
1868    let record_len = block_size
1869        .checked_add(BLOCK_RECORD_FRAMING_LEN)
1870        .ok_or(FormatError::InvalidArchive("BlockRecord length overflow"))?;
1871    let mut offset = crypto_end;
1872    let mut observed_block_count = 0u64;
1873    let mut metadata_seen = false;
1874    let mut pending = PendingSequentialEnvelope::default();
1875    let mut next_envelope_index = 0u64;
1876    let mut tar_stream = Vec::new();
1877
1878    while bytes.get(offset..offset + 4) == Some(b"TZBK") {
1879        let record = parse_sequential_block_or_erasure(
1880            bytes,
1881            offset,
1882            record_len,
1883            block_size,
1884            &volume_header,
1885            observed_block_count,
1886        )?;
1887        observed_block_count = observed_block_count
1888            .checked_add(1)
1889            .ok_or(FormatError::InvalidArchive("BlockRecord count overflow"))?;
1890        let Some(record) = record else {
1891            handle_sequential_payload_erasure(&mut pending, &parsed_crypto.fixed, metadata_seen)?;
1892            offset = checked_add(offset, record_len, "BlockRecord")?;
1893            continue;
1894        };
1895
1896        match record.kind {
1897            BlockKind::PayloadData => {
1898                if metadata_seen {
1899                    return Err(FormatError::InvalidArchive(
1900                        "payload BlockRecord appears after metadata",
1901                    ));
1902                }
1903                if pending.awaiting_tentative_parity {
1904                    return Err(FormatError::InvalidArchive(
1905                        "sequential payload envelope boundary is ambiguous after CRC erasure",
1906                    ));
1907                }
1908                if pending.saw_last_data {
1909                    finalize_sequential_envelope(
1910                        &mut pending,
1911                        &parsed_crypto.fixed,
1912                        &subkeys,
1913                        &volume_header,
1914                        &mut next_envelope_index,
1915                        &mut tar_stream,
1916                    )?;
1917                }
1918                let is_last_data = record.is_last_data();
1919                pending.data_shards.push(Some(record.payload));
1920                if is_last_data {
1921                    pending.saw_last_data = true;
1922                }
1923                if pending.data_shards.len() > parsed_crypto.fixed.fec_data_shards as usize {
1924                    return Err(FormatError::InvalidArchive(
1925                        "sequential payload envelope exceeds data-shard cap",
1926                    ));
1927                }
1928            }
1929            BlockKind::PayloadParity => {
1930                if metadata_seen {
1931                    return Err(FormatError::InvalidArchive(
1932                        "payload parity BlockRecord appears after metadata",
1933                    ));
1934                }
1935                if pending.awaiting_tentative_parity {
1936                    pending.awaiting_tentative_parity = false;
1937                    pending.saw_last_data = true;
1938                } else if pending.data_shards.is_empty() || !pending.saw_last_data {
1939                    return Err(FormatError::InvalidArchive(
1940                        "payload parity appears before envelope data is complete",
1941                    ));
1942                }
1943                pending.parity_shards.push(Some(record.payload));
1944                if pending.parity_shards.len() > parsed_crypto.fixed.fec_parity_shards as usize {
1945                    return Err(FormatError::InvalidArchive(
1946                        "sequential payload envelope exceeds parity-shard cap",
1947                    ));
1948                }
1949            }
1950            _ => {
1951                if !pending.is_empty() {
1952                    finalize_sequential_envelope(
1953                        &mut pending,
1954                        &parsed_crypto.fixed,
1955                        &subkeys,
1956                        &volume_header,
1957                        &mut next_envelope_index,
1958                        &mut tar_stream,
1959                    )?;
1960                }
1961                metadata_seen = true;
1962            }
1963        }
1964
1965        offset = checked_add(offset, record_len, "BlockRecord")?;
1966    }
1967
1968    if !pending.is_empty() {
1969        finalize_sequential_envelope(
1970            &mut pending,
1971            &parsed_crypto.fixed,
1972            &subkeys,
1973            &volume_header,
1974            &mut next_envelope_index,
1975            &mut tar_stream,
1976        )?;
1977    }
1978
1979    parse_terminal_material(
1980        bytes,
1981        offset,
1982        observed_block_count,
1983        &subkeys,
1984        &volume_header,
1985        parsed_crypto.fixed.block_size,
1986    )?;
1987    let observed_archive_bytes = observed_archive_size([bytes.len() as u64])?;
1988    validate_tar_stream_total_extraction_size(
1989        &tar_stream,
1990        parsed_crypto.fixed.max_path_length,
1991        total_extraction_size_cap(options, observed_archive_bytes),
1992    )?;
1993    Ok(tar_stream)
1994}
1995
1996fn validate_sequential_supported_volume(
1997    volume_header: &VolumeHeader,
1998    crypto_header: &CryptoHeaderFixed,
1999) -> Result<(), FormatError> {
2000    if volume_header.stripe_width != 1 || volume_header.volume_index != 0 {
2001        return Err(FormatError::ReaderUnsupported(
2002            "M9 sequential reader supports only single-volume archives",
2003        ));
2004    }
2005    if crypto_header.stripe_width != volume_header.stripe_width {
2006        return Err(FormatError::InvalidArchive(
2007            "VolumeHeader and CryptoHeader stripe_width differ",
2008        ));
2009    }
2010    if crypto_header.has_dictionary != 0 {
2011        return Err(FormatError::ReaderUnsupported(
2012            "dictionary bootstrap required for non-seekable sequential extraction",
2013        ));
2014    }
2015    Ok(())
2016}
2017
2018fn finalize_sequential_envelope(
2019    pending: &mut PendingSequentialEnvelope,
2020    crypto_header: &CryptoHeaderFixed,
2021    subkeys: &Subkeys,
2022    volume_header: &VolumeHeader,
2023    next_envelope_index: &mut u64,
2024    tar_stream: &mut Vec<u8>,
2025) -> Result<(), FormatError> {
2026    if !pending.saw_last_data {
2027        return Err(FormatError::InvalidArchive(
2028            "sequential payload envelope is missing last-data flag",
2029        ));
2030    }
2031    if pending.data_shards.len() > crypto_header.fec_data_shards as usize {
2032        return Err(FormatError::InvalidArchive(
2033            "sequential payload envelope exceeds data-shard cap",
2034        ));
2035    }
2036    if pending.parity_shards.len() > crypto_header.fec_parity_shards as usize {
2037        return Err(FormatError::InvalidArchive(
2038            "sequential payload envelope exceeds parity-shard cap",
2039        ));
2040    }
2041    let required_parity = required_object_parity(pending.data_shards.len() as u64, crypto_header)?;
2042    if pending.parity_shards.len() < required_parity as usize {
2043        return Err(FormatError::InvalidArchive(
2044            "sequential payload envelope has insufficient parity for recovery settings",
2045        ));
2046    }
2047
2048    let repaired = repair_data_gf16(
2049        &pending.data_shards,
2050        &pending.parity_shards,
2051        crypto_header.block_size as usize,
2052    )?;
2053    let mut encrypted = Vec::with_capacity(repaired.len() * crypto_header.block_size as usize);
2054    for shard in repaired {
2055        encrypted.extend_from_slice(&shard);
2056    }
2057    let plaintext = decrypt_padded_aead_object(
2058        crypto_header.aead_algo,
2059        &subkeys.enc_key,
2060        &subkeys.nonce_seed,
2061        b"envelope",
2062        &volume_header.archive_uuid,
2063        &volume_header.session_id,
2064        *next_envelope_index,
2065        &encrypted,
2066    )?;
2067    decode_concatenated_zstd_frames(&plaintext, None, tar_stream)?;
2068    *next_envelope_index = next_envelope_index
2069        .checked_add(1)
2070        .ok_or(FormatError::InvalidArchive("envelope counter overflow"))?;
2071    *pending = PendingSequentialEnvelope::default();
2072    Ok(())
2073}
2074
2075fn decode_concatenated_zstd_frames(
2076    plaintext: &[u8],
2077    dictionary: Option<&[u8]>,
2078    output: &mut Vec<u8>,
2079) -> Result<(), FormatError> {
2080    let mut cursor = 0usize;
2081    while cursor < plaintext.len() {
2082        let frame_len = zstd_safe::find_frame_compressed_size(&plaintext[cursor..])
2083            .map_err(|_| FormatError::InvalidZstdFrame)?;
2084        if frame_len == 0 {
2085            return Err(FormatError::InvalidZstdFrame);
2086        }
2087        let end = checked_add(cursor, frame_len, "zstd frame")?;
2088        validate_exact_zstd_frame(&plaintext[cursor..end])?;
2089        let decoded = if let Some(dictionary) = dictionary {
2090            let mut decoder =
2091                zstd::stream::Decoder::with_dictionary(&plaintext[cursor..end], dictionary)
2092                    .map_err(|_| FormatError::ZstdDecompressionFailure)?;
2093            let mut decoded = Vec::new();
2094            decoder
2095                .read_to_end(&mut decoded)
2096                .map_err(|_| FormatError::ZstdDecompressionFailure)?;
2097            decoded
2098        } else {
2099            zstd::stream::decode_all(&plaintext[cursor..end])
2100                .map_err(|_| FormatError::ZstdDecompressionFailure)?
2101        };
2102        output.extend_from_slice(&decoded);
2103        cursor = end;
2104    }
2105    Ok(())
2106}
2107
2108fn load_archive_dictionary(
2109    blocks: &BTreeMap<u64, BlockRecord>,
2110    subkeys: &Subkeys,
2111    volume_header: &VolumeHeader,
2112    crypto_header: &CryptoHeaderFixed,
2113    index_root: &IndexRoot,
2114) -> Result<Option<Vec<u8>>, FormatError> {
2115    if crypto_header.has_dictionary == 0 {
2116        return Ok(None);
2117    }
2118    let plaintext = load_metadata_object_from_parts(
2119        blocks,
2120        subkeys,
2121        volume_header,
2122        crypto_header,
2123        dictionary_extent_from_index_root(index_root)?,
2124        BlockKind::DictionaryData,
2125        BlockKind::DictionaryParity,
2126        &subkeys.dictionary_key,
2127        &subkeys.index_nonce_seed,
2128        b"dict",
2129        0,
2130        crypto_header.index_root_fec_data_shards,
2131        crypto_header.index_root_fec_parity_shards,
2132        index_root.header.dictionary_decompressed_size,
2133    )?;
2134    Ok(Some(plaintext))
2135}
2136
2137fn dictionary_extent_from_index_root(index_root: &IndexRoot) -> Result<ObjectExtent, FormatError> {
2138    if index_root.header.dictionary_data_block_count == 0
2139        || index_root.header.dictionary_encrypted_size == 0
2140        || index_root.header.dictionary_decompressed_size == 0
2141    {
2142        return Err(FormatError::InvalidArchive("dictionary bootstrap required"));
2143    }
2144    Ok(ObjectExtent {
2145        first_block_index: index_root.header.dictionary_first_block,
2146        data_block_count: index_root.header.dictionary_data_block_count,
2147        parity_block_count: index_root.header.dictionary_parity_block_count,
2148        encrypted_size: index_root.header.dictionary_encrypted_size,
2149    })
2150}
2151
2152fn load_metadata_object_from_parts(
2153    blocks: &BTreeMap<u64, BlockRecord>,
2154    subkeys: &Subkeys,
2155    volume_header: &VolumeHeader,
2156    crypto_header: &CryptoHeaderFixed,
2157    extent: ObjectExtent,
2158    data_kind: BlockKind,
2159    parity_kind: BlockKind,
2160    key: &[u8; 32],
2161    nonce_seed: &[u8; 32],
2162    domain: &[u8],
2163    counter: u64,
2164    class_data_shard_max: u16,
2165    class_parity_shard_max: u16,
2166    decompressed_size: u32,
2167) -> Result<Vec<u8>, FormatError> {
2168    let compressed = load_decrypted_object_from_parts(
2169        blocks,
2170        volume_header,
2171        crypto_header,
2172        extent,
2173        data_kind,
2174        parity_kind,
2175        key,
2176        nonce_seed,
2177        domain,
2178        counter,
2179        class_data_shard_max,
2180        class_parity_shard_max,
2181    )?;
2182    let _ = subkeys;
2183    decompress_exact_zstd_frame(&compressed, decompressed_size as usize)
2184}
2185
2186fn load_decrypted_object_from_parts(
2187    blocks: &BTreeMap<u64, BlockRecord>,
2188    volume_header: &VolumeHeader,
2189    crypto_header: &CryptoHeaderFixed,
2190    extent: ObjectExtent,
2191    data_kind: BlockKind,
2192    parity_kind: BlockKind,
2193    key: &[u8; 32],
2194    nonce_seed: &[u8; 32],
2195    domain: &[u8],
2196    counter: u64,
2197    class_data_shard_max: u16,
2198    class_parity_shard_max: u16,
2199) -> Result<Vec<u8>, FormatError> {
2200    validate_object_extent(
2201        extent,
2202        crypto_header,
2203        class_data_shard_max,
2204        class_parity_shard_max,
2205    )?;
2206    let block_size = crypto_header.block_size as usize;
2207    let data_count = extent.data_block_count as usize;
2208    let parity_count = extent.parity_block_count as usize;
2209    let mut data_shards = Vec::with_capacity(data_count);
2210    let mut parity_shards = Vec::with_capacity(parity_count);
2211
2212    for offset in 0..data_count {
2213        let block_index = checked_u64_add(extent.first_block_index, offset as u64, "object")?;
2214        if let Some(record) = blocks.get(&block_index) {
2215            if record.kind != data_kind {
2216                return Err(FormatError::InvalidArchive(
2217                    "object data block has unexpected kind",
2218                ));
2219            }
2220            let should_be_last = offset + 1 == data_count;
2221            if record.is_last_data() != should_be_last {
2222                return Err(FormatError::InvalidArchive(
2223                    "object last-data flag is not on the final data block",
2224                ));
2225            }
2226            data_shards.push(Some(record.payload.clone()));
2227        } else {
2228            data_shards.push(None);
2229        }
2230    }
2231
2232    for offset in 0..parity_count {
2233        let block_index = checked_u64_add(
2234            extent.first_block_index,
2235            data_count as u64 + offset as u64,
2236            "object",
2237        )?;
2238        if let Some(record) = blocks.get(&block_index) {
2239            if record.kind != parity_kind {
2240                return Err(FormatError::InvalidArchive(
2241                    "object parity block has unexpected kind",
2242                ));
2243            }
2244            if record.is_last_data() {
2245                return Err(FormatError::InvalidArchive(
2246                    "object parity block has last-data flag",
2247                ));
2248            }
2249            parity_shards.push(Some(record.payload.clone()));
2250        } else {
2251            parity_shards.push(None);
2252        }
2253    }
2254
2255    let repaired = repair_data_gf16(&data_shards, &parity_shards, block_size)?;
2256    let mut encrypted = Vec::with_capacity(extent.encrypted_size as usize);
2257    for shard in repaired {
2258        encrypted.extend_from_slice(&shard);
2259    }
2260    if encrypted.len() != extent.encrypted_size as usize {
2261        return Err(FormatError::InvalidArchive(
2262            "object encrypted size does not match repaired shards",
2263        ));
2264    }
2265
2266    decrypt_padded_aead_object(
2267        crypto_header.aead_algo,
2268        key,
2269        nonce_seed,
2270        domain,
2271        &volume_header.archive_uuid,
2272        &volume_header.session_id,
2273        counter,
2274        &encrypted,
2275    )
2276}
2277
2278fn validate_object_extent(
2279    extent: ObjectExtent,
2280    crypto_header: &CryptoHeaderFixed,
2281    class_data_shard_max: u16,
2282    class_parity_shard_max: u16,
2283) -> Result<(), FormatError> {
2284    if extent.data_block_count == 0 || extent.encrypted_size == 0 {
2285        return Err(FormatError::InvalidArchive(
2286            "encrypted object has zero data blocks or size",
2287        ));
2288    }
2289    if extent.data_block_count > class_data_shard_max as u32 {
2290        return Err(FormatError::InvalidArchive(
2291            "encrypted object exceeds its class data-shard maximum",
2292        ));
2293    }
2294    if extent.parity_block_count > class_parity_shard_max as u32 {
2295        return Err(FormatError::InvalidArchive(
2296            "encrypted object exceeds its class parity-shard maximum",
2297        ));
2298    }
2299    let required_parity = required_object_parity(extent.data_block_count as u64, crypto_header)?;
2300    if extent.parity_block_count < required_parity {
2301        return Err(FormatError::InvalidArchive(
2302            "encrypted object has insufficient parity for recovery settings",
2303        ));
2304    }
2305    let total = checked_u64_add(
2306        extent.data_block_count as u64,
2307        extent.parity_block_count as u64,
2308        "encrypted object shard count overflow",
2309    )?;
2310    if total > 65_535 {
2311        return Err(FormatError::FecTooManyShards(total as usize));
2312    }
2313    let expected = checked_u64_mul(
2314        extent.data_block_count as u64,
2315        crypto_header.block_size as u64,
2316        "encrypted object size overflow",
2317    )?;
2318    if expected != extent.encrypted_size as u64 {
2319        return Err(FormatError::InvalidArchive(
2320            "encrypted object size is not data_block_count * block_size",
2321        ));
2322    }
2323    if extent.encrypted_size as usize <= crypto_header.aead_algo.tag_len() {
2324        return Err(FormatError::InvalidArchive(
2325            "encrypted object is too small for AEAD tag",
2326        ));
2327    }
2328    Ok(())
2329}
2330
2331fn required_object_parity(
2332    data_block_count: u64,
2333    crypto_header: &CryptoHeaderFixed,
2334) -> Result<u32, FormatError> {
2335    let min_parity =
2336        if crypto_header.volume_loss_tolerance > 0 || crypto_header.bit_rot_buffer_pct > 0 {
2337            1
2338        } else {
2339            0
2340        };
2341    let mut parity = 0u64;
2342    for _ in 0..100 {
2343        let total = data_block_count
2344            .checked_add(parity)
2345            .ok_or(FormatError::InvalidArchive("parity total overflow"))?;
2346        let by_volume = checked_u64_mul(
2347            crypto_header.volume_loss_tolerance as u64,
2348            ceil_div_u64(total, crypto_header.stripe_width as u64)?,
2349            "volume-loss parity overflow",
2350        )?;
2351        let by_bitrot = ceil_div_u64(
2352            checked_u64_mul(
2353                total,
2354                crypto_header.bit_rot_buffer_pct as u64,
2355                "bit-rot parity overflow",
2356            )?,
2357            100,
2358        )?;
2359        let next = by_volume
2360            .checked_add(by_bitrot)
2361            .ok_or(FormatError::InvalidArchive("parity overflow"))?
2362            .max(min_parity);
2363        if next == parity {
2364            return u32::try_from(next)
2365                .map_err(|_| FormatError::InvalidArchive("parity count overflow"));
2366        }
2367        parity = next;
2368    }
2369    Err(FormatError::InvalidArchive(
2370        "parity calculation did not converge",
2371    ))
2372}
2373
2374fn ceil_div_u64(numerator: u64, denominator: u64) -> Result<u64, FormatError> {
2375    if denominator == 0 {
2376        return Err(FormatError::InvalidArchive("division by zero"));
2377    }
2378    numerator
2379        .checked_add(denominator - 1)
2380        .ok_or(FormatError::InvalidArchive("ceiling division overflow"))
2381        .map(|value| value / denominator)
2382}
2383
2384fn frame_range_for_file<'b>(
2385    shard: &'b IndexShard,
2386    file: &FileEntry,
2387) -> Result<Vec<&'b FrameEntry>, FormatError> {
2388    let mut frames = Vec::with_capacity(file.frame_count as usize);
2389    for offset in 0..file.frame_count as u64 {
2390        let frame_index =
2391            file.first_frame_index
2392                .checked_add(offset)
2393                .ok_or(FormatError::InvalidArchive(
2394                    "FileEntry frame range overflow",
2395                ))?;
2396        let frame = shard
2397            .frames
2398            .iter()
2399            .find(|entry| entry.frame_index == frame_index)
2400            .ok_or(FormatError::InvalidArchive(
2401                "FileEntry references missing FrameEntry",
2402            ))?;
2403        frames.push(frame);
2404    }
2405    Ok(frames)
2406}
2407
2408fn metadata_limits(crypto_header: &CryptoHeaderFixed) -> MetadataLimits {
2409    MetadataLimits {
2410        block_size: crypto_header.block_size,
2411        max_path_length: crypto_header.max_path_length,
2412        max_payload_data_shards: crypto_header.fec_data_shards,
2413        max_payload_parity_shards: crypto_header.fec_parity_shards,
2414        max_index_data_shards: crypto_header.index_fec_data_shards,
2415        max_index_parity_shards: crypto_header.index_fec_parity_shards,
2416        max_index_root_data_shards: crypto_header.index_root_fec_data_shards,
2417        max_index_root_parity_shards: crypto_header.index_root_fec_parity_shards,
2418        ..MetadataLimits::default()
2419    }
2420}
2421
2422fn verify_dense_keys<T>(
2423    entries: &BTreeMap<u64, T>,
2424    expected_count: u64,
2425    structure: &'static str,
2426) -> Result<(), FormatError> {
2427    if entries.len() as u64 != expected_count {
2428        return Err(FormatError::InvalidArchive(
2429            "decoded table count does not match IndexRoot",
2430        ));
2431    }
2432    for expected in 0..expected_count {
2433        if !entries.contains_key(&expected) {
2434            return Err(FormatError::InvalidMetadata {
2435                structure,
2436                reason: "global index coverage has a gap",
2437            });
2438        }
2439    }
2440    Ok(())
2441}
2442
2443fn validate_envelope_frame_coverage(
2444    frames: &BTreeMap<u64, FrameEntry>,
2445    envelopes: &BTreeMap<u64, EnvelopeEntry>,
2446) -> Result<(), FormatError> {
2447    let mut accounted_frames = BTreeSet::new();
2448    for envelope in envelopes.values() {
2449        let first = envelope.first_frame_index;
2450        let end =
2451            first
2452                .checked_add(envelope.frame_count as u64)
2453                .ok_or(FormatError::InvalidArchive(
2454                    "EnvelopeEntry frame range overflow",
2455                ))?;
2456        let mut ranges = Vec::with_capacity(envelope.frame_count as usize);
2457        for frame_index in first..end {
2458            let frame = frames.get(&frame_index).ok_or(FormatError::InvalidArchive(
2459                "EnvelopeEntry references missing FrameEntry",
2460            ))?;
2461            if frame.envelope_index != envelope.envelope_index {
2462                return Err(FormatError::InvalidArchive(
2463                    "FrameEntry envelope_index does not match containing EnvelopeEntry",
2464                ));
2465            }
2466            if !accounted_frames.insert(frame_index) {
2467                return Err(FormatError::InvalidArchive(
2468                    "FrameEntry is covered by multiple EnvelopeEntries",
2469                ));
2470            }
2471            let start = frame.offset_in_envelope as usize;
2472            let end = checked_add(start, frame.compressed_size as usize, "FrameEntry")?;
2473            if end > envelope.plaintext_size as usize {
2474                return Err(FormatError::InvalidArchive(
2475                    "FrameEntry exceeds EnvelopeEntry plaintext_size",
2476                ));
2477            }
2478            ranges.push((start, end));
2479        }
2480        validate_exact_coverage_ranges(
2481            &mut ranges,
2482            envelope.plaintext_size as usize,
2483            "EnvelopeEntry frame coverage has a gap or overlap",
2484        )?;
2485    }
2486
2487    for frame_index in frames.keys() {
2488        if !accounted_frames.contains(frame_index) {
2489            return Err(FormatError::InvalidArchive(
2490                "FrameEntry is not covered by any EnvelopeEntry",
2491            ));
2492        }
2493    }
2494    Ok(())
2495}
2496
2497fn validate_file_extent_coverage_ranges(
2498    extents: &[(u64, u64)],
2499    tar_len: usize,
2500) -> Result<(), FormatError> {
2501    let mut ranges = Vec::with_capacity(extents.len());
2502    for (start, len) in extents {
2503        let start = to_usize(*start, "FileEntry")?;
2504        let len = to_usize(*len, "FileEntry")?;
2505        let end = checked_add(start, len, "FileEntry")?;
2506        if end > tar_len {
2507            return Err(FormatError::InvalidArchive(
2508                "FileEntry extent exceeds IndexRoot tar_total_size",
2509            ));
2510        }
2511        ranges.push((start, end));
2512    }
2513    validate_exact_coverage_ranges(
2514        &mut ranges,
2515        tar_len,
2516        "FileEntry extents do not cover tar stream exactly",
2517    )
2518}
2519
2520fn add_expected_directory_hint_rows(
2521    map: &mut DirectoryHintMap,
2522    shard_row_index: u32,
2523    path: &[u8],
2524    kind: TarEntryKind,
2525) {
2526    map.entry(Vec::new()).or_default().insert(shard_row_index);
2527    for (idx, byte) in path.iter().enumerate() {
2528        if *byte == b'/' {
2529            map.entry(path[..idx].to_vec())
2530                .or_default()
2531                .insert(shard_row_index);
2532        }
2533    }
2534    if kind == TarEntryKind::Directory {
2535        map.entry(path.to_vec())
2536            .or_default()
2537            .insert(shard_row_index);
2538    }
2539}
2540
2541fn validate_directory_hint_tables_against_expected(
2542    tables: &[DirectoryHintTable],
2543    expected: &DirectoryHintMap,
2544) -> Result<(), FormatError> {
2545    let mut actual = Vec::new();
2546    let mut previous_key: Option<([u8; 8], Vec<u8>)> = None;
2547
2548    for table in tables {
2549        for entry_index in 0..table.entries.len() {
2550            let path = table
2551                .entry_path(entry_index)
2552                .ok_or(FormatError::InvalidArchive(
2553                    "DirectoryHintEntry path is missing",
2554                ))?;
2555            let key = (hash_prefix(path), path.to_vec());
2556            if let Some(previous) = &previous_key {
2557                if previous >= &key {
2558                    return Err(FormatError::InvalidArchive(
2559                        "DirectoryHintEntry rows are not globally sorted",
2560                    ));
2561                }
2562            }
2563            previous_key = Some(key);
2564
2565            let rows =
2566                table
2567                    .shard_rows_for_entry(entry_index)
2568                    .ok_or(FormatError::InvalidArchive(
2569                        "DirectoryHintEntry shard rows are missing",
2570                    ))?;
2571            actual.push((path.to_vec(), rows.to_vec()));
2572        }
2573    }
2574
2575    if actual != sorted_directory_hint_rows(expected) {
2576        return Err(FormatError::InvalidArchive(
2577            "directory hint map does not match decoded files",
2578        ));
2579    }
2580    Ok(())
2581}
2582
2583fn sorted_directory_hint_rows(map: &DirectoryHintMap) -> Vec<(Vec<u8>, Vec<u32>)> {
2584    let mut rows = map
2585        .iter()
2586        .map(|(path, shard_rows)| {
2587            (
2588                path.clone(),
2589                shard_rows.iter().copied().collect::<Vec<u32>>(),
2590            )
2591        })
2592        .collect::<Vec<_>>();
2593    rows.sort_by(|(left_path, _), (right_path, _)| {
2594        hash_prefix(left_path)
2595            .cmp(&hash_prefix(right_path))
2596            .then_with(|| left_path.cmp(right_path))
2597    });
2598    rows
2599}
2600
2601fn validate_exact_coverage_ranges(
2602    ranges: &mut [(usize, usize)],
2603    expected_end: usize,
2604    reason: &'static str,
2605) -> Result<(), FormatError> {
2606    ranges.sort_unstable();
2607    let mut cursor = 0usize;
2608    for (start, end) in ranges.iter().copied() {
2609        if start != cursor || end < start {
2610            return Err(FormatError::InvalidArchive(reason));
2611        }
2612        cursor = end;
2613    }
2614    if cursor != expected_end {
2615        return Err(FormatError::InvalidArchive(reason));
2616    }
2617    Ok(())
2618}
2619
2620fn object_block_range(
2621    first_block_index: u64,
2622    data_block_count: u32,
2623    parity_block_count: u32,
2624    structure: &'static str,
2625) -> Result<(u64, u64), FormatError> {
2626    let total = data_block_count as u64 + parity_block_count as u64;
2627    if total == 0 {
2628        return Err(FormatError::InvalidArchive(structure));
2629    }
2630    let end = checked_u64_add(first_block_index, total, structure)?;
2631    Ok((first_block_index, end))
2632}
2633
2634fn validate_non_overlapping_object_ranges(ranges: &mut [(u64, u64)]) -> Result<(), FormatError> {
2635    ranges.sort_unstable();
2636    for pair in ranges.windows(2) {
2637        if pair[0].1 > pair[1].0 {
2638            return Err(FormatError::InvalidArchive(
2639                "encrypted object block ranges overlap",
2640            ));
2641        }
2642    }
2643    Ok(())
2644}
2645
2646fn observed_archive_size(sizes: impl IntoIterator<Item = u64>) -> Result<u64, FormatError> {
2647    sizes.into_iter().try_fold(0u64, |sum, size| {
2648        sum.checked_add(size).ok_or(FormatError::InvalidArchive(
2649            "observed archive size overflow",
2650        ))
2651    })
2652}
2653
2654fn total_extraction_size_cap(options: ReaderOptions, observed_archive_bytes: u64) -> u64 {
2655    options
2656        .max_total_extraction_size
2657        .min(observed_archive_bytes.saturating_mul(10))
2658}
2659
2660fn utf8_path(bytes: &[u8]) -> Result<String, FormatError> {
2661    std::str::from_utf8(bytes)
2662        .map(|path| path.to_owned())
2663        .map_err(|_| FormatError::UnsafeArchivePath)
2664}
2665
2666fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
2667    let digest = Sha256::digest(bytes);
2668    let mut out = [0u8; 32];
2669    out.copy_from_slice(&digest);
2670    out
2671}
2672
2673fn slice<'b>(
2674    bytes: &'b [u8],
2675    offset: usize,
2676    len: usize,
2677    structure: &'static str,
2678) -> Result<&'b [u8], FormatError> {
2679    let end = checked_add(offset, len, structure)?;
2680    bytes.get(offset..end).ok_or(FormatError::InvalidLength {
2681        structure,
2682        expected: end,
2683        actual: bytes.len(),
2684    })
2685}
2686
2687fn checked_add(lhs: usize, rhs: usize, structure: &'static str) -> Result<usize, FormatError> {
2688    lhs.checked_add(rhs)
2689        .ok_or(FormatError::InvalidArchive(structure))
2690}
2691
2692fn checked_u64_add(lhs: u64, rhs: u64, structure: &'static str) -> Result<u64, FormatError> {
2693    lhs.checked_add(rhs)
2694        .ok_or(FormatError::InvalidArchive(structure))
2695}
2696
2697fn to_usize(value: u64, structure: &'static str) -> Result<usize, FormatError> {
2698    usize::try_from(value).map_err(|_| FormatError::InvalidArchive(structure))
2699}
2700
2701#[cfg(test)]
2702mod tests {
2703    use super::*;
2704    use crate::crypto::compute_hmac;
2705    use crate::format::{AeadAlgo, CompressionAlgo, FecAlgo, KdfAlgo};
2706    use crate::metadata::{DirectoryHintEntry, DirectoryHintTableHeader};
2707    use crate::writer::{write_archive, write_archive_with_dictionary, RegularFile, WriterOptions};
2708
2709    fn master_key() -> MasterKey {
2710        MasterKey::from_raw_key(&[0x42; 32]).unwrap()
2711    }
2712
2713    fn dictionary() -> &'static [u8] {
2714        b"dir/dict.txt common words common words common words dictionary payload"
2715    }
2716
2717    fn single_stream_options() -> WriterOptions {
2718        WriterOptions {
2719            stripe_width: 1,
2720            volume_loss_tolerance: 0,
2721            ..WriterOptions::default()
2722        }
2723    }
2724
2725    #[test]
2726    fn opens_lists_verifies_and_extracts_one_file_archive() {
2727        let archive = write_archive(
2728            &[RegularFile::new("dir/hello.txt", b"hello m7")],
2729            &master_key(),
2730            single_stream_options(),
2731        )
2732        .unwrap();
2733        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2734
2735        assert_eq!(
2736            opened.list_files().unwrap(),
2737            vec![ArchiveEntry {
2738                path: "dir/hello.txt".to_string(),
2739                file_data_size: 8,
2740                kind: TarEntryKind::Regular,
2741                mode: 0o644,
2742                mtime: 0,
2743                diagnostics: Vec::new(),
2744            }]
2745        );
2746        opened.verify().unwrap();
2747        assert_eq!(
2748            opened.extract_file("dir/hello.txt").unwrap(),
2749            Some(b"hello m7".to_vec())
2750        );
2751        assert_eq!(opened.extract_file("missing.txt").unwrap(), None);
2752    }
2753
2754    #[test]
2755    fn safe_extract_writes_regular_file_under_root() {
2756        let archive = write_archive(
2757            &[RegularFile::new("dir/hello.txt", b"safe m8")],
2758            &master_key(),
2759            single_stream_options(),
2760        )
2761        .unwrap();
2762        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2763        let tmp = tempfile::tempdir().unwrap();
2764
2765        opened
2766            .extract_file_to(
2767                "dir/hello.txt",
2768                tmp.path(),
2769                SafeExtractionOptions::default(),
2770            )
2771            .unwrap()
2772            .unwrap();
2773
2774        assert_eq!(
2775            std::fs::read(tmp.path().join("dir").join("hello.txt")).unwrap(),
2776            b"safe m8"
2777        );
2778    }
2779
2780    #[test]
2781    fn safe_extract_rejects_overwriting_existing_file_by_default() {
2782        let archive = write_archive(
2783            &[RegularFile::new("hello.txt", b"new")],
2784            &master_key(),
2785            single_stream_options(),
2786        )
2787        .unwrap();
2788        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2789        let tmp = tempfile::tempdir().unwrap();
2790        std::fs::write(tmp.path().join("hello.txt"), b"old").unwrap();
2791
2792        assert_eq!(
2793            opened
2794                .extract_file_to("hello.txt", tmp.path(), SafeExtractionOptions::default())
2795                .unwrap_err(),
2796            FormatError::UnsafeOverwrite
2797        );
2798        assert_eq!(std::fs::read(tmp.path().join("hello.txt")).unwrap(), b"old");
2799    }
2800
2801    #[test]
2802    fn opens_and_verifies_empty_archive() {
2803        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
2804        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2805
2806        assert!(opened.list_files().unwrap().is_empty());
2807        opened.verify().unwrap();
2808    }
2809
2810    #[test]
2811    fn default_reader_options_allow_v36_trailing_garbage_scan() {
2812        let archive = write_archive(
2813            &[RegularFile::new("garbage-tolerant.txt", b"still intact")],
2814            &master_key(),
2815            single_stream_options(),
2816        )
2817        .unwrap();
2818        let mut with_trailing_garbage = archive.bytes.clone();
2819        with_trailing_garbage.extend_from_slice(b"ignored trailing bytes");
2820
2821        let opened = open_archive(&with_trailing_garbage, &master_key()).unwrap();
2822        assert_eq!(
2823            opened.extract_file("garbage-tolerant.txt").unwrap(),
2824            Some(b"still intact".to_vec())
2825        );
2826    }
2827
2828    #[test]
2829    fn rejects_wrong_key_before_metadata_release() {
2830        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
2831        let wrong = MasterKey::from_raw_key(&[0x43; 32]).unwrap();
2832
2833        assert_eq!(
2834            open_archive(&archive.bytes, &wrong).unwrap_err(),
2835            FormatError::HmacMismatch {
2836                structure: "CryptoHeader"
2837            }
2838        );
2839    }
2840
2841    #[test]
2842    fn rejects_payload_tamper_even_with_recomputed_block_crc() {
2843        let mut archive = write_archive(
2844            &[RegularFile::new("file.txt", b"authenticated")],
2845            &master_key(),
2846            single_stream_options(),
2847        )
2848        .unwrap()
2849        .bytes;
2850        let volume = VolumeHeader::parse(&archive[..VOLUME_HEADER_LEN]).unwrap();
2851        let crypto_end = VOLUME_HEADER_LEN + usize::try_from(volume.crypto_header_length).unwrap();
2852        let crypto = CryptoHeader::parse(
2853            &archive[VOLUME_HEADER_LEN..crypto_end],
2854            volume.crypto_header_length,
2855        )
2856        .unwrap();
2857        let block_size = crypto.fixed.block_size as usize;
2858        archive[crypto_end + 16] ^= 1;
2859        let crc_offset = crypto_end + 16 + block_size;
2860        let crc = crc32c::crc32c(&archive[crypto_end..crc_offset]);
2861        archive[crc_offset..crc_offset + 4].copy_from_slice(&crc.to_le_bytes());
2862
2863        let opened = open_archive(&archive, &master_key()).unwrap();
2864        assert_eq!(opened.verify().unwrap_err(), FormatError::AeadFailure);
2865    }
2866
2867    #[test]
2868    fn list_and_extract_use_final_view_for_duplicate_paths() {
2869        let archive = write_archive(
2870            &[
2871                RegularFile::new("same.txt", b"old"),
2872                RegularFile::new("same.txt", b"newer"),
2873            ],
2874            &master_key(),
2875            single_stream_options(),
2876        )
2877        .unwrap();
2878        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2879
2880        assert_eq!(
2881            opened.list_files().unwrap(),
2882            vec![ArchiveEntry {
2883                path: "same.txt".to_string(),
2884                file_data_size: 5,
2885                kind: TarEntryKind::Regular,
2886                mode: 0o644,
2887                mtime: 0,
2888                diagnostics: Vec::new(),
2889            }]
2890        );
2891        assert_eq!(
2892            opened.extract_file("same.txt").unwrap(),
2893            Some(b"newer".to_vec())
2894        );
2895        opened.verify().unwrap();
2896    }
2897
2898    #[test]
2899    fn bootstrap_sidecar_opens_lists_verifies_and_extracts() {
2900        let archive = write_archive(
2901            &[RegularFile::new("dir/sidecar.txt", b"hello sidecar")],
2902            &master_key(),
2903            single_stream_options(),
2904        )
2905        .unwrap();
2906        let opened = open_archive_with_bootstrap_sidecar(
2907            &archive.bytes,
2908            &archive.bootstrap_sidecar,
2909            &master_key(),
2910        )
2911        .unwrap();
2912
2913        assert_eq!(
2914            opened.list_files().unwrap(),
2915            vec![ArchiveEntry {
2916                path: "dir/sidecar.txt".to_string(),
2917                file_data_size: 13,
2918                kind: TarEntryKind::Regular,
2919                mode: 0o644,
2920                mtime: 0,
2921                diagnostics: Vec::new(),
2922            }]
2923        );
2924        assert_eq!(
2925            opened.extract_file("dir/sidecar.txt").unwrap(),
2926            Some(b"hello sidecar".to_vec())
2927        );
2928        opened.verify().unwrap();
2929    }
2930
2931    #[test]
2932    fn dictionary_archive_opens_lists_verifies_and_extracts_seekable() {
2933        let archive = write_archive_with_dictionary(
2934            &[RegularFile::new(
2935                "dir/dict.txt",
2936                b"common words common words dictionary payload",
2937            )],
2938            &master_key(),
2939            single_stream_options(),
2940            dictionary(),
2941        )
2942        .unwrap();
2943        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2944
2945        assert_eq!(opened.crypto_header.has_dictionary, 1);
2946        assert!(opened.index_root.header.dictionary_data_block_count > 0);
2947        assert_eq!(
2948            opened.list_files().unwrap(),
2949            vec![ArchiveEntry {
2950                path: "dir/dict.txt".to_string(),
2951                file_data_size: 44,
2952                kind: TarEntryKind::Regular,
2953                mode: 0o644,
2954                mtime: 0,
2955                diagnostics: Vec::new(),
2956            }]
2957        );
2958        assert_eq!(
2959            opened.extract_file("dir/dict.txt").unwrap(),
2960            Some(b"common words common words dictionary payload".to_vec())
2961        );
2962        opened.verify().unwrap();
2963    }
2964
2965    #[test]
2966    fn dictionary_object_tamper_fails_before_payload_decompression() {
2967        let archive = write_archive_with_dictionary(
2968            &[RegularFile::new(
2969                "dir/dict.txt",
2970                b"common words common words dictionary payload",
2971            )],
2972            &master_key(),
2973            single_stream_options(),
2974            dictionary(),
2975        )
2976        .unwrap();
2977        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
2978        let volume_header = VolumeHeader::parse(&archive.bytes[..VOLUME_HEADER_LEN]).unwrap();
2979        let crypto_end = VOLUME_HEADER_LEN + volume_header.crypto_header_length as usize;
2980        let record_len = opened.crypto_header.block_size as usize + BLOCK_RECORD_FRAMING_LEN;
2981        let dictionary_offset =
2982            crypto_end + opened.index_root.header.dictionary_first_block as usize * record_len;
2983
2984        let mut tampered = archive.bytes.clone();
2985        tampered[dictionary_offset + 16] ^= 0x01;
2986        let crc_offset = dictionary_offset + 16 + opened.crypto_header.block_size as usize;
2987        let crc = crc32c::crc32c(&tampered[dictionary_offset..crc_offset]);
2988        tampered[crc_offset..crc_offset + 4].copy_from_slice(&crc.to_le_bytes());
2989
2990        assert_eq!(
2991            open_archive(&tampered, &master_key()).unwrap_err(),
2992            FormatError::AeadFailure
2993        );
2994    }
2995
2996    #[test]
2997    fn dictionary_archive_bootstraps_from_sidecar_for_non_seekable_open() {
2998        let archive = write_archive_with_dictionary(
2999            &[RegularFile::new(
3000                "dict-sidecar.txt",
3001                b"common words common words sidecar payload",
3002            )],
3003            &master_key(),
3004            single_stream_options(),
3005            dictionary(),
3006        )
3007        .unwrap();
3008        let opened = open_non_seekable_archive(
3009            &archive.bytes,
3010            &master_key(),
3011            Some(&archive.bootstrap_sidecar),
3012        )
3013        .unwrap();
3014
3015        assert_eq!(
3016            opened.extract_file("dict-sidecar.txt").unwrap(),
3017            Some(b"common words common words sidecar payload".to_vec())
3018        );
3019        opened.verify().unwrap();
3020    }
3021
3022    #[test]
3023    fn bootstrap_sidecar_treats_crc_failed_payload_block_as_erasure() {
3024        let archive = write_archive(
3025            &[RegularFile::new(
3026                "sidecar-erasure.txt",
3027                b"repair through sidecar",
3028            )],
3029            &master_key(),
3030            single_stream_options(),
3031        )
3032        .unwrap();
3033        let mut corrupted = archive.bytes.clone();
3034        corrupt_first_block_record_payload(&mut corrupted);
3035
3036        let opened = open_archive_with_bootstrap_sidecar(
3037            &corrupted,
3038            &archive.bootstrap_sidecar,
3039            &master_key(),
3040        )
3041        .unwrap();
3042        assert_eq!(
3043            opened.extract_file("sidecar-erasure.txt").unwrap(),
3044            Some(b"repair through sidecar".to_vec())
3045        );
3046    }
3047
3048    #[test]
3049    fn extraction_rejects_logical_payload_above_total_size_cap() {
3050        let archive = write_archive(
3051            &[RegularFile::new("cap.txt", b"payload")],
3052            &master_key(),
3053            single_stream_options(),
3054        )
3055        .unwrap();
3056        let mut options = ReaderOptions::default();
3057        options.max_total_extraction_size = 3;
3058        let opened =
3059            OpenedArchive::open_with_options(&archive.bytes, &master_key(), options).unwrap();
3060
3061        assert_eq!(
3062            opened.extract_file("cap.txt").unwrap_err(),
3063            FormatError::ReaderUnsupported("total extraction size exceeds configured cap")
3064        );
3065    }
3066
3067    #[test]
3068    fn verify_does_not_apply_extraction_payload_cap() {
3069        let archive = write_archive(
3070            &[RegularFile::new("verify-cap.txt", b"payload")],
3071            &master_key(),
3072            single_stream_options(),
3073        )
3074        .unwrap();
3075        let mut options = ReaderOptions::default();
3076        options.max_total_extraction_size = 3;
3077        let opened =
3078            OpenedArchive::open_with_options(&archive.bytes, &master_key(), options).unwrap();
3079
3080        opened.verify().unwrap();
3081        assert_eq!(
3082            opened.extract_file("verify-cap.txt").unwrap_err(),
3083            FormatError::ReaderUnsupported("total extraction size exceeds configured cap")
3084        );
3085    }
3086
3087    #[test]
3088    fn dictionary_sidecar_requires_dictionary_record_section() {
3089        let archive = write_archive_with_dictionary(
3090            &[RegularFile::new("dict-missing.txt", b"common words")],
3091            &master_key(),
3092            single_stream_options(),
3093            dictionary(),
3094        )
3095        .unwrap();
3096        let header = BootstrapSidecarHeader::parse(
3097            &archive.bootstrap_sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN],
3098        )
3099        .unwrap();
3100        let mut missing_dictionary =
3101            archive.bootstrap_sidecar[..header.dictionary_records_offset as usize].to_vec();
3102        rewrite_sidecar_header(&mut missing_dictionary, &master_key(), |header| {
3103            header.flags &= !0x04;
3104            header.dictionary_records_offset = 0;
3105            header.dictionary_records_length = 0;
3106        });
3107
3108        assert_eq!(
3109            open_archive_with_bootstrap_sidecar(&archive.bytes, &missing_dictionary, &master_key())
3110                .unwrap_err(),
3111            FormatError::ReaderUnsupported("dictionary bootstrap required")
3112        );
3113    }
3114
3115    #[test]
3116    fn dictionary_sidecar_records_are_validated_against_dictionary_extent() {
3117        let archive = write_archive_with_dictionary(
3118            &[RegularFile::new("dict-sidecar-kind.txt", b"common words")],
3119            &master_key(),
3120            single_stream_options(),
3121            dictionary(),
3122        )
3123        .unwrap();
3124
3125        let mut wrong_kind = archive.bootstrap_sidecar.clone();
3126        mutate_sidecar_dictionary_record(&mut wrong_kind, 0, |record| {
3127            record.kind = BlockKind::IndexRootData;
3128        });
3129        assert_eq!(
3130            open_archive_with_bootstrap_sidecar(&archive.bytes, &wrong_kind, &master_key())
3131                .unwrap_err(),
3132            FormatError::InvalidArchive("sidecar BlockRecord section has wrong kind")
3133        );
3134
3135        let mut wrong_last = archive.bootstrap_sidecar.clone();
3136        mutate_sidecar_dictionary_record(&mut wrong_last, 0, |record| {
3137            record.flags = 0;
3138        });
3139        assert_eq!(
3140            open_archive_with_bootstrap_sidecar(&archive.bytes, &wrong_last, &master_key())
3141                .unwrap_err(),
3142            FormatError::InvalidArchive("sidecar BlockRecord section has wrong last-data flag")
3143        );
3144    }
3145
3146    #[test]
3147    fn non_seekable_random_access_requires_sidecar() {
3148        let archive = write_archive(
3149            &[RegularFile::new("file.txt", b"payload")],
3150            &master_key(),
3151            single_stream_options(),
3152        )
3153        .unwrap();
3154
3155        assert_eq!(
3156            open_non_seekable_archive(&archive.bytes, &master_key(), None).unwrap_err(),
3157            FormatError::ReaderUnsupported(
3158                "non-seekable random access requires a bootstrap sidecar"
3159            )
3160        );
3161        assert!(open_non_seekable_archive(
3162            &archive.bytes,
3163            &master_key(),
3164            Some(&archive.bootstrap_sidecar)
3165        )
3166        .is_ok());
3167    }
3168
3169    #[test]
3170    fn sequential_extracts_dictionary_free_tar_stream() {
3171        let archive = write_archive(
3172            &[RegularFile::new("seq.txt", b"streaming")],
3173            &master_key(),
3174            single_stream_options(),
3175        )
3176        .unwrap();
3177
3178        let tar_stream = sequential_extract_tar_stream(&archive.bytes, &master_key()).unwrap();
3179        let member = parse_tar_member_group(&tar_stream, 4096).unwrap();
3180        assert_eq!(member.path, b"seq.txt");
3181        assert_eq!(member.data, b"streaming");
3182    }
3183
3184    #[test]
3185    fn sequential_rejects_logical_payload_above_total_size_cap() {
3186        let archive = write_archive(
3187            &[RegularFile::new("seq-cap.txt", b"payload")],
3188            &master_key(),
3189            single_stream_options(),
3190        )
3191        .unwrap();
3192        let mut options = ReaderOptions::default();
3193        options.max_total_extraction_size = 3;
3194
3195        assert_eq!(
3196            sequential_extract_tar_stream_with_options(&archive.bytes, &master_key(), options)
3197                .unwrap_err(),
3198            FormatError::ReaderUnsupported("total extraction size exceeds configured cap")
3199        );
3200    }
3201
3202    #[test]
3203    fn sequential_repairs_crc_failed_payload_data_when_parity_is_guaranteed() {
3204        let archive = write_archive(
3205            &[RegularFile::new("seq-erasure.txt", b"stream repair")],
3206            &master_key(),
3207            single_stream_options(),
3208        )
3209        .unwrap();
3210        let mut corrupted = archive.bytes;
3211        corrupt_first_block_record_payload(&mut corrupted);
3212
3213        let tar_stream = sequential_extract_tar_stream(&corrupted, &master_key()).unwrap();
3214        let member = parse_tar_member_group(&tar_stream, 4096).unwrap();
3215        assert_eq!(member.path, b"seq-erasure.txt");
3216        assert_eq!(member.data, b"stream repair");
3217    }
3218
3219    #[test]
3220    fn sequential_rejects_crc_failed_payload_data_without_guaranteed_parity() {
3221        let archive = write_archive(
3222            &[RegularFile::new("seq-no-parity.txt", b"no repair")],
3223            &master_key(),
3224            WriterOptions {
3225                bit_rot_buffer_pct: 0,
3226                fec_parity_shards: 0,
3227                index_fec_parity_shards: 0,
3228                index_root_fec_parity_shards: 0,
3229                ..single_stream_options()
3230            },
3231        )
3232        .unwrap();
3233        let mut corrupted = archive.bytes;
3234        corrupt_first_block_record_payload(&mut corrupted);
3235
3236        assert_eq!(
3237            sequential_extract_tar_stream(&corrupted, &master_key()).unwrap_err(),
3238            FormatError::BadCrc {
3239                structure: "BlockRecord"
3240            }
3241        );
3242    }
3243
3244    #[test]
3245    fn sequential_rejects_when_terminal_authentication_fails() {
3246        let archive = write_archive(
3247            &[RegularFile::new("seq.txt", b"streaming")],
3248            &master_key(),
3249            single_stream_options(),
3250        )
3251        .unwrap();
3252        let mut corrupted = archive.bytes;
3253        let trailer_hmac_offset = corrupted.len() - VOLUME_TRAILER_LEN + TRAILER_HMAC_COVERED_LEN;
3254        corrupted[trailer_hmac_offset] ^= 0x01;
3255
3256        assert_eq!(
3257            sequential_extract_tar_stream(&corrupted, &master_key()).unwrap_err(),
3258            FormatError::HmacMismatch {
3259                structure: "VolumeTrailer"
3260            }
3261        );
3262    }
3263
3264    #[test]
3265    fn sequential_zstd_stream_rejects_skippable_frame_segments() {
3266        let skippable = [0x50, 0x2a, 0x4d, 0x18, 0, 0, 0, 0];
3267        let mut output = Vec::new();
3268
3269        assert_eq!(
3270            decode_concatenated_zstd_frames(&skippable, None, &mut output).unwrap_err(),
3271            FormatError::NotStandardZstdFrame
3272        );
3273        assert!(output.is_empty());
3274    }
3275
3276    #[test]
3277    fn bootstrap_sidecar_rejects_bad_flags_and_trailing_bytes() {
3278        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
3279        let mut bad_flags = archive.bootstrap_sidecar.clone();
3280        rewrite_sidecar_header(&mut bad_flags, &master_key(), |header| {
3281            header.flags |= 0x08;
3282        });
3283        assert_eq!(
3284            open_archive_with_bootstrap_sidecar(&archive.bytes, &bad_flags, &master_key())
3285                .unwrap_err(),
3286            FormatError::UnknownBootstrapSidecarFlags(0x0b)
3287        );
3288
3289        let mut trailing = archive.bootstrap_sidecar.clone();
3290        trailing.push(0);
3291        assert_eq!(
3292            open_archive_with_bootstrap_sidecar(&archive.bytes, &trailing, &master_key())
3293                .unwrap_err(),
3294            FormatError::NonCanonicalBootstrapSidecarLayout
3295        );
3296    }
3297
3298    #[test]
3299    fn bootstrap_sidecar_rejects_bad_manifest_footer_semantics() {
3300        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
3301        let mut wrong_volume = archive.bootstrap_sidecar.clone();
3302        mutate_sidecar_manifest(&mut wrong_volume, &master_key(), |footer| {
3303            footer.volume_index = 1;
3304        });
3305        assert_eq!(
3306            open_archive_with_bootstrap_sidecar(&archive.bytes, &wrong_volume, &master_key())
3307                .unwrap_err(),
3308            FormatError::InvalidArchive("sidecar ManifestFooter volume_index must be zero")
3309        );
3310
3311        let mut non_authoritative = archive.bootstrap_sidecar.clone();
3312        mutate_sidecar_manifest(&mut non_authoritative, &master_key(), |footer| {
3313            footer.is_authoritative = 0;
3314        });
3315        assert_eq!(
3316            open_archive_with_bootstrap_sidecar(&archive.bytes, &non_authoritative, &master_key())
3317                .unwrap_err(),
3318            FormatError::InvalidArchive("sidecar ManifestFooter is not authoritative")
3319        );
3320    }
3321
3322    #[test]
3323    fn bootstrap_sidecar_rejects_dictionary_section_for_no_dictionary_archive() {
3324        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
3325        let mut with_dictionary = archive.bootstrap_sidecar.clone();
3326        let header =
3327            BootstrapSidecarHeader::parse(&with_dictionary[..BOOTSTRAP_SIDECAR_HEADER_LEN])
3328                .unwrap();
3329        let record_len = sidecar_record_len(&with_dictionary);
3330        let first_record = header.index_root_records_offset as usize;
3331        let copied_record = with_dictionary[first_record..first_record + record_len].to_vec();
3332        let dictionary_offset = with_dictionary.len() as u64;
3333        with_dictionary.extend_from_slice(&copied_record);
3334        rewrite_sidecar_header(&mut with_dictionary, &master_key(), |header| {
3335            header.flags |= 0x04;
3336            header.dictionary_records_offset = dictionary_offset;
3337            header.dictionary_records_length = record_len as u64;
3338        });
3339
3340        assert_eq!(
3341            open_archive_with_bootstrap_sidecar(&archive.bytes, &with_dictionary, &master_key())
3342                .unwrap_err(),
3343            FormatError::InvalidArchive(
3344                "bootstrap sidecar has dictionary records while has_dictionary is false"
3345            )
3346        );
3347    }
3348
3349    #[test]
3350    fn bootstrap_sidecar_rejects_missing_duplicate_wrong_kind_and_wrong_last_flag() {
3351        let archive = write_archive(&[], &master_key(), single_stream_options()).unwrap();
3352        let mut missing = archive.bootstrap_sidecar.clone();
3353        let record_len = sidecar_record_len(&missing);
3354        let new_len = missing.len() - record_len;
3355        missing.truncate(new_len);
3356        rewrite_sidecar_header(&mut missing, &master_key(), |header| {
3357            header.index_root_records_length -= record_len as u64;
3358        });
3359        assert_eq!(
3360            open_archive_with_bootstrap_sidecar(&archive.bytes, &missing, &master_key())
3361                .unwrap_err(),
3362            FormatError::InvalidArchive(
3363                "sidecar BlockRecord section does not match declared extent"
3364            )
3365        );
3366
3367        let mut duplicate = archive.bootstrap_sidecar.clone();
3368        mutate_sidecar_index_record(&mut duplicate, 1, |record| {
3369            record.block_index -= 1;
3370        });
3371        assert_eq!(
3372            open_archive_with_bootstrap_sidecar(&archive.bytes, &duplicate, &master_key())
3373                .unwrap_err(),
3374            FormatError::InvalidArchive(
3375                "sidecar BlockRecord section has missing or duplicate blocks"
3376            )
3377        );
3378
3379        let mut misordered = archive.bootstrap_sidecar.clone();
3380        swap_sidecar_index_records(&mut misordered, 0, 1);
3381        assert_eq!(
3382            open_archive_with_bootstrap_sidecar(&archive.bytes, &misordered, &master_key())
3383                .unwrap_err(),
3384            FormatError::InvalidArchive(
3385                "sidecar BlockRecord section has missing or duplicate blocks"
3386            )
3387        );
3388
3389        let mut wrong_kind = archive.bootstrap_sidecar.clone();
3390        mutate_sidecar_index_record(&mut wrong_kind, 0, |record| {
3391            record.kind = BlockKind::PayloadData;
3392        });
3393        assert_eq!(
3394            open_archive_with_bootstrap_sidecar(&archive.bytes, &wrong_kind, &master_key())
3395                .unwrap_err(),
3396            FormatError::InvalidArchive("sidecar BlockRecord section has wrong kind")
3397        );
3398
3399        let mut wrong_last = archive.bootstrap_sidecar.clone();
3400        mutate_sidecar_index_record(&mut wrong_last, 0, |record| {
3401            record.flags = 0;
3402        });
3403        assert_eq!(
3404            open_archive_with_bootstrap_sidecar(&archive.bytes, &wrong_last, &master_key())
3405                .unwrap_err(),
3406            FormatError::InvalidArchive("sidecar BlockRecord section has wrong last-data flag")
3407        );
3408    }
3409
3410    #[test]
3411    fn verify_helper_rejects_envelope_frame_coverage_gap() {
3412        let frames = BTreeMap::from([(
3413            0,
3414            FrameEntry {
3415                frame_index: 0,
3416                envelope_index: 0,
3417                offset_in_envelope: 0,
3418                compressed_size: 10,
3419                decompressed_size: 512,
3420                flags: 0,
3421                tar_stream_offset: 0,
3422            },
3423        )]);
3424        let envelopes = BTreeMap::from([(
3425            0,
3426            EnvelopeEntry {
3427                envelope_index: 0,
3428                first_block_index: 0,
3429                data_block_count: 1,
3430                parity_block_count: 1,
3431                encrypted_size: 4096,
3432                plaintext_size: 11,
3433                first_frame_index: 0,
3434                frame_count: 1,
3435            },
3436        )]);
3437
3438        assert_eq!(
3439            validate_envelope_frame_coverage(&frames, &envelopes).unwrap_err(),
3440            FormatError::InvalidArchive("EnvelopeEntry frame coverage has a gap or overlap")
3441        );
3442    }
3443
3444    #[test]
3445    fn verify_helper_rejects_file_extent_gaps_and_overlaps() {
3446        assert!(validate_file_extent_coverage_ranges(&[(512, 512), (0, 512)], 1024).is_ok());
3447        assert_eq!(
3448            validate_file_extent_coverage_ranges(&[(0, 512), (1024, 512)], 1536).unwrap_err(),
3449            FormatError::InvalidArchive("FileEntry extents do not cover tar stream exactly")
3450        );
3451        assert_eq!(
3452            validate_file_extent_coverage_ranges(&[(0, 1024), (512, 512)], 1024).unwrap_err(),
3453            FormatError::InvalidArchive("FileEntry extents do not cover tar stream exactly")
3454        );
3455    }
3456
3457    #[test]
3458    fn expected_directory_hint_rows_include_ancestors_and_directory_entries() {
3459        let mut map = DirectoryHintMap::new();
3460        add_expected_directory_hint_rows(&mut map, 2, b"foo/bar/baz.txt", TarEntryKind::Regular);
3461        add_expected_directory_hint_rows(&mut map, 4, b"foo/bar", TarEntryKind::Directory);
3462
3463        assert_eq!(map.get(&Vec::new()), Some(&BTreeSet::from([2, 4])));
3464        assert_eq!(map.get(&b"foo".to_vec()), Some(&BTreeSet::from([2, 4])));
3465        assert_eq!(map.get(&b"foo/bar".to_vec()), Some(&BTreeSet::from([2, 4])));
3466        assert!(!map.contains_key(&b"foo/bar/baz.txt".to_vec()));
3467        assert!(!map.contains_key(&b"foobar".to_vec()));
3468    }
3469
3470    #[test]
3471    fn directory_hint_validation_requires_exact_global_map() {
3472        let mut expected = DirectoryHintMap::new();
3473        add_expected_directory_hint_rows(&mut expected, 0, b"foo/bar.txt", TarEntryKind::Regular);
3474        add_expected_directory_hint_rows(&mut expected, 1, b"foo", TarEntryKind::Directory);
3475        let rows = sorted_directory_hint_rows(&expected);
3476        let table = directory_hint_table_from_rows(7, &rows, 2);
3477
3478        validate_directory_hint_tables_against_expected(&[table.clone()], &expected).unwrap();
3479
3480        let mut incomplete = expected.clone();
3481        incomplete.get_mut(&b"foo".to_vec()).unwrap().remove(&1);
3482        assert_eq!(
3483            validate_directory_hint_tables_against_expected(&[table], &incomplete).unwrap_err(),
3484            FormatError::InvalidArchive("directory hint map does not match decoded files")
3485        );
3486    }
3487
3488    #[test]
3489    fn directory_hint_validation_rejects_global_order_mismatch() {
3490        let mut expected = DirectoryHintMap::new();
3491        expected.insert(Vec::new(), BTreeSet::from([0]));
3492        expected.insert(b"alpha".to_vec(), BTreeSet::from([0]));
3493        let rows = sorted_directory_hint_rows(&expected);
3494        let first = directory_hint_table_from_rows(8, &rows[..1], 1);
3495        let second = directory_hint_table_from_rows(9, &rows[1..], 1);
3496
3497        assert_eq!(
3498            validate_directory_hint_tables_against_expected(&[second, first], &expected)
3499                .unwrap_err(),
3500            FormatError::InvalidArchive("DirectoryHintEntry rows are not globally sorted")
3501        );
3502    }
3503
3504    #[test]
3505    fn object_extent_rejects_parity_above_class_cap() {
3506        let crypto_header = CryptoHeaderFixed {
3507            length: 0,
3508            compression_algo: CompressionAlgo::ZstdFramed,
3509            aead_algo: AeadAlgo::AesGcmSiv256,
3510            fec_algo: FecAlgo::ReedSolomonGF16,
3511            kdf_algo: KdfAlgo::Raw,
3512            chunk_size: 1024,
3513            envelope_target_size: 4096,
3514            block_size: 4096,
3515            fec_data_shards: 1,
3516            fec_parity_shards: 1,
3517            index_fec_data_shards: 1,
3518            index_fec_parity_shards: 1,
3519            index_root_fec_data_shards: 1,
3520            index_root_fec_parity_shards: 1,
3521            stripe_width: 1,
3522            volume_loss_tolerance: 0,
3523            bit_rot_buffer_pct: 0,
3524            has_dictionary: 0,
3525            max_path_length: 4096,
3526            expected_volume_size: 0,
3527        };
3528        let extent = ObjectExtent {
3529            first_block_index: 0,
3530            data_block_count: 1,
3531            parity_block_count: 2,
3532            encrypted_size: 4096,
3533        };
3534
3535        assert_eq!(
3536            validate_object_extent(extent, &crypto_header, 1, 1).unwrap_err(),
3537            FormatError::InvalidArchive("encrypted object exceeds its class parity-shard maximum")
3538        );
3539    }
3540
3541    #[test]
3542    fn object_extent_rejects_parity_below_recoverability_requirement() {
3543        let crypto_header = CryptoHeaderFixed {
3544            length: 0,
3545            compression_algo: CompressionAlgo::ZstdFramed,
3546            aead_algo: AeadAlgo::AesGcmSiv256,
3547            fec_algo: FecAlgo::ReedSolomonGF16,
3548            kdf_algo: KdfAlgo::Raw,
3549            chunk_size: 1024,
3550            envelope_target_size: 4096,
3551            block_size: 4096,
3552            fec_data_shards: 1,
3553            fec_parity_shards: 1,
3554            index_fec_data_shards: 1,
3555            index_fec_parity_shards: 1,
3556            index_root_fec_data_shards: 1,
3557            index_root_fec_parity_shards: 1,
3558            stripe_width: 2,
3559            volume_loss_tolerance: 1,
3560            bit_rot_buffer_pct: 0,
3561            has_dictionary: 0,
3562            max_path_length: 4096,
3563            expected_volume_size: 0,
3564        };
3565        let extent = ObjectExtent {
3566            first_block_index: 0,
3567            data_block_count: 1,
3568            parity_block_count: 0,
3569            encrypted_size: 4096,
3570        };
3571
3572        assert_eq!(
3573            validate_object_extent(extent, &crypto_header, 1, 1).unwrap_err(),
3574            FormatError::InvalidArchive(
3575                "encrypted object has insufficient parity for recovery settings"
3576            )
3577        );
3578    }
3579
3580    #[test]
3581    fn opens_complete_multi_volume_archive() {
3582        let files = [RegularFile::new("alpha.txt", b"hello from volume stripes")];
3583        let archive = write_archive(
3584            &files,
3585            &master_key(),
3586            WriterOptions {
3587                stripe_width: 2,
3588                volume_loss_tolerance: 1,
3589                ..single_stream_options()
3590            },
3591        )
3592        .unwrap();
3593        assert_eq!(archive.volumes.len(), 2);
3594
3595        let volume_refs = archive
3596            .volumes
3597            .iter()
3598            .map(Vec::as_slice)
3599            .collect::<Vec<_>>();
3600        let opened = open_archive_volumes(&volume_refs, &master_key()).unwrap();
3601
3602        assert_eq!(opened.volume_header.stripe_width, 2);
3603        assert_eq!(opened.list_files().unwrap()[0].path, "alpha.txt");
3604        assert_eq!(
3605            opened.extract_file("alpha.txt").unwrap(),
3606            Some(b"hello from volume stripes".to_vec())
3607        );
3608        opened.verify().unwrap();
3609    }
3610
3611    #[test]
3612    fn recovers_from_one_missing_volume_when_parity_allows() {
3613        let files = [RegularFile::new("alpha.txt", b"recover me")];
3614        let archive = write_archive(
3615            &files,
3616            &master_key(),
3617            WriterOptions {
3618                stripe_width: 2,
3619                volume_loss_tolerance: 1,
3620                ..single_stream_options()
3621            },
3622        )
3623        .unwrap();
3624
3625        let recovered =
3626            open_archive_volumes(&[archive.volumes[1].as_slice()], &master_key()).unwrap();
3627        assert_eq!(
3628            recovered.extract_file("alpha.txt").unwrap(),
3629            Some(b"recover me".to_vec())
3630        );
3631        recovered.verify().unwrap();
3632    }
3633
3634    #[test]
3635    fn recovers_from_crc_corrupted_block_when_parity_allows() {
3636        let files = [RegularFile::new("alpha.txt", b"repair corrupt block")];
3637        let archive = write_archive(
3638            &files,
3639            &master_key(),
3640            WriterOptions {
3641                stripe_width: 2,
3642                volume_loss_tolerance: 1,
3643                ..single_stream_options()
3644            },
3645        )
3646        .unwrap();
3647        let mut volumes = archive.volumes.clone();
3648        corrupt_first_block_record_payload(&mut volumes[0]);
3649
3650        let volume_refs = volumes.iter().map(Vec::as_slice).collect::<Vec<_>>();
3651        let recovered = open_archive_volumes(&volume_refs, &master_key()).unwrap();
3652
3653        assert_eq!(
3654            recovered.extract_file("alpha.txt").unwrap(),
3655            Some(b"repair corrupt block".to_vec())
3656        );
3657        recovered.verify().unwrap();
3658    }
3659
3660    #[test]
3661    fn rejects_block_record_at_wrong_stripe_position() {
3662        let files = [RegularFile::new("alpha.txt", b"wrong stripe")];
3663        let archive = write_archive(
3664            &files,
3665            &master_key(),
3666            WriterOptions {
3667                stripe_width: 2,
3668                volume_loss_tolerance: 1,
3669                ..single_stream_options()
3670            },
3671        )
3672        .unwrap();
3673        let mut volumes = archive.volumes.clone();
3674        mutate_first_block_record(&mut volumes[0], |record| {
3675            record.block_index += 2;
3676        });
3677
3678        let volume_refs = volumes.iter().map(Vec::as_slice).collect::<Vec<_>>();
3679        assert_eq!(
3680            open_archive_volumes(&volume_refs, &master_key()).unwrap_err(),
3681            FormatError::InvalidArchive("BlockRecord index does not match volume position")
3682        );
3683    }
3684
3685    #[test]
3686    fn rejects_duplicate_authenticated_volume_indexes() {
3687        let files = [RegularFile::new("alpha.txt", b"duplicates")];
3688        let archive = write_archive(
3689            &files,
3690            &master_key(),
3691            WriterOptions {
3692                stripe_width: 2,
3693                volume_loss_tolerance: 1,
3694                ..single_stream_options()
3695            },
3696        )
3697        .unwrap();
3698
3699        assert_eq!(
3700            open_archive_volumes(
3701                &[archive.volumes[0].as_slice(), archive.volumes[0].as_slice()],
3702                &master_key()
3703            )
3704            .unwrap_err(),
3705            FormatError::InvalidArchive("duplicate authenticated volume index")
3706        );
3707    }
3708
3709    fn directory_hint_table_from_rows(
3710        hint_shard_index: u64,
3711        rows: &[(Vec<u8>, Vec<u32>)],
3712        shard_count: u32,
3713    ) -> DirectoryHintTable {
3714        let mut entries = Vec::new();
3715        let mut shard_row_indexes = Vec::new();
3716        let mut string_pool = Vec::new();
3717
3718        for (path, rows) in rows {
3719            let path_offset = if path.is_empty() {
3720                0
3721            } else {
3722                let offset = string_pool.len() as u64;
3723                string_pool.extend_from_slice(path);
3724                offset
3725            };
3726            let shard_list_start_index = shard_row_indexes.len() as u32;
3727            shard_row_indexes.extend_from_slice(rows);
3728            entries.push(DirectoryHintEntry {
3729                dir_hash: hash_prefix(path),
3730                path_offset,
3731                path_length: path.len() as u32,
3732                shard_list_start_index,
3733                shard_count: rows.len() as u32,
3734            });
3735        }
3736
3737        let table_bytes =
3738            directory_hint_table_bytes(hint_shard_index, entries, shard_row_indexes, string_pool);
3739        let locating = DirectoryHintShardEntry {
3740            hint_shard_index,
3741            first_dir_hash: hash_prefix(&rows.first().unwrap().0),
3742            last_dir_hash: hash_prefix(&rows.last().unwrap().0),
3743            first_block_index: 0,
3744            data_block_count: 1,
3745            parity_block_count: 0,
3746            encrypted_size: 4096,
3747            decompressed_size: table_bytes.len() as u32,
3748            entry_count: rows.len() as u64,
3749        };
3750        DirectoryHintTable::parse(
3751            &table_bytes,
3752            &locating,
3753            shard_count,
3754            MetadataLimits::default(),
3755        )
3756        .unwrap()
3757    }
3758
3759    fn directory_hint_table_bytes(
3760        hint_shard_index: u64,
3761        entries: Vec<DirectoryHintEntry>,
3762        shard_row_indexes: Vec<u32>,
3763        string_pool: Vec<u8>,
3764    ) -> Vec<u8> {
3765        let header_len = DirectoryHintTableHeader {
3766            version: 1,
3767            hint_shard_index,
3768            entry_count: 0,
3769            entry_table_offset: 0,
3770            shard_list_offset: 0,
3771            string_pool_offset: 0,
3772            string_pool_size: 0,
3773        }
3774        .to_bytes()
3775        .len();
3776        let entry_len = entries
3777            .first()
3778            .map(|entry| entry.to_bytes().len())
3779            .unwrap_or(0);
3780        let shard_list_offset = if entries.is_empty() {
3781            0
3782        } else {
3783            header_len + entries.len() * entry_len
3784        };
3785        let string_pool_offset = if string_pool.is_empty() {
3786            0
3787        } else {
3788            shard_list_offset + shard_row_indexes.len() * 4
3789        };
3790
3791        let header = DirectoryHintTableHeader {
3792            version: 1,
3793            hint_shard_index,
3794            entry_count: entries.len() as u64,
3795            entry_table_offset: if entries.is_empty() {
3796                0
3797            } else {
3798                header_len as u64
3799            },
3800            shard_list_offset: shard_list_offset as u64,
3801            string_pool_offset: string_pool_offset as u64,
3802            string_pool_size: string_pool.len() as u64,
3803        };
3804
3805        let mut out = Vec::new();
3806        out.extend_from_slice(&header.to_bytes());
3807        for entry in entries {
3808            out.extend_from_slice(&entry.to_bytes());
3809        }
3810        for row in shard_row_indexes {
3811            out.extend_from_slice(&row.to_le_bytes());
3812        }
3813        out.extend_from_slice(&string_pool);
3814        out
3815    }
3816
3817    fn corrupt_first_block_record_payload(volume: &mut [u8]) {
3818        let (record_offset, _) = first_block_record(volume);
3819        volume[record_offset + 16] ^= 0x55;
3820    }
3821
3822    fn mutate_first_block_record(volume: &mut [u8], mutate: impl FnOnce(&mut BlockRecord)) {
3823        let (record_offset, record_len) = first_block_record(volume);
3824        let block_size = record_len - BLOCK_RECORD_FRAMING_LEN;
3825        let mut record = BlockRecord::parse(
3826            &volume[record_offset..record_offset + record_len],
3827            block_size,
3828        )
3829        .unwrap();
3830        mutate(&mut record);
3831        volume[record_offset..record_offset + record_len].copy_from_slice(&record.to_bytes());
3832    }
3833
3834    fn first_block_record(volume: &[u8]) -> (usize, usize) {
3835        let volume_header = VolumeHeader::parse(&volume[..VOLUME_HEADER_LEN]).unwrap();
3836        let crypto_start = volume_header.crypto_header_offset as usize;
3837        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
3838        let crypto_header = CryptoHeader::parse(
3839            &volume[crypto_start..crypto_end],
3840            volume_header.crypto_header_length,
3841        )
3842        .unwrap();
3843        let record_offset = crypto_end;
3844        let record_len = crypto_header.fixed.block_size as usize + BLOCK_RECORD_FRAMING_LEN;
3845        assert!(volume.len() >= record_offset + record_len);
3846        (record_offset, record_len)
3847    }
3848
3849    fn rewrite_sidecar_header(
3850        sidecar: &mut [u8],
3851        master_key: &MasterKey,
3852        mutate: impl FnOnce(&mut BootstrapSidecarHeader),
3853    ) {
3854        let mut header =
3855            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3856        mutate(&mut header);
3857        header.sidecar_hmac = [0u8; 32];
3858        let mut header_bytes = header.to_bytes();
3859        let subkeys =
3860            Subkeys::derive(master_key, &header.archive_uuid, &header.session_id).unwrap();
3861        header.sidecar_hmac = compute_hmac(
3862            HmacDomain::BootstrapSidecar,
3863            &subkeys.mac_key,
3864            &header.archive_uuid,
3865            &header.session_id,
3866            &header_bytes[..SIDECAR_HMAC_COVERED_LEN],
3867        );
3868        header_bytes = header.to_bytes();
3869        sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN].copy_from_slice(&header_bytes);
3870    }
3871
3872    fn mutate_sidecar_manifest(
3873        sidecar: &mut [u8],
3874        master_key: &MasterKey,
3875        mutate: impl FnOnce(&mut ManifestFooter),
3876    ) {
3877        let header =
3878            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3879        let offset = header.manifest_footer_offset as usize;
3880        let mut footer =
3881            ManifestFooter::parse(&sidecar[offset..offset + MANIFEST_FOOTER_LEN]).unwrap();
3882        mutate(&mut footer);
3883        footer.manifest_hmac = [0u8; 32];
3884        let mut footer_bytes = footer.to_bytes();
3885        let subkeys =
3886            Subkeys::derive(master_key, &footer.archive_uuid, &footer.session_id).unwrap();
3887        footer.manifest_hmac = compute_hmac(
3888            HmacDomain::ManifestFooter,
3889            &subkeys.mac_key,
3890            &footer.archive_uuid,
3891            &footer.session_id,
3892            &footer_bytes[..MANIFEST_HMAC_COVERED_LEN],
3893        );
3894        footer_bytes = footer.to_bytes();
3895        sidecar[offset..offset + MANIFEST_FOOTER_LEN].copy_from_slice(&footer_bytes);
3896    }
3897
3898    fn mutate_sidecar_index_record(
3899        sidecar: &mut [u8],
3900        record_index: usize,
3901        mutate: impl FnOnce(&mut BlockRecord),
3902    ) {
3903        let header =
3904            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3905        let record_len = sidecar_record_len(sidecar);
3906        let offset = header.index_root_records_offset as usize + record_index * record_len;
3907        let block_size = record_len - BLOCK_RECORD_FRAMING_LEN;
3908        let mut record =
3909            BlockRecord::parse(&sidecar[offset..offset + record_len], block_size).unwrap();
3910        mutate(&mut record);
3911        sidecar[offset..offset + record_len].copy_from_slice(&record.to_bytes());
3912    }
3913
3914    fn mutate_sidecar_dictionary_record(
3915        sidecar: &mut [u8],
3916        record_index: usize,
3917        mutate: impl FnOnce(&mut BlockRecord),
3918    ) {
3919        let header =
3920            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3921        let record_len = sidecar_record_len(sidecar);
3922        let offset = header.dictionary_records_offset as usize + record_index * record_len;
3923        let block_size = record_len - BLOCK_RECORD_FRAMING_LEN;
3924        let mut record =
3925            BlockRecord::parse(&sidecar[offset..offset + record_len], block_size).unwrap();
3926        mutate(&mut record);
3927        sidecar[offset..offset + record_len].copy_from_slice(&record.to_bytes());
3928    }
3929
3930    fn swap_sidecar_index_records(sidecar: &mut [u8], left: usize, right: usize) {
3931        let header =
3932            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3933        let record_len = sidecar_record_len(sidecar);
3934        let left_offset = header.index_root_records_offset as usize + left * record_len;
3935        let right_offset = header.index_root_records_offset as usize + right * record_len;
3936        for idx in 0..record_len {
3937            sidecar.swap(left_offset + idx, right_offset + idx);
3938        }
3939    }
3940
3941    fn sidecar_record_len(sidecar: &[u8]) -> usize {
3942        let header =
3943            BootstrapSidecarHeader::parse(&sidecar[..BOOTSTRAP_SIDECAR_HEADER_LEN]).unwrap();
3944        let footer_offset = header.manifest_footer_offset as usize;
3945        let footer =
3946            ManifestFooter::parse(&sidecar[footer_offset..footer_offset + MANIFEST_FOOTER_LEN])
3947                .unwrap();
3948        let index_record_count = footer.index_root_data_block_count as usize
3949            + footer.index_root_parity_block_count as usize;
3950        header.index_root_records_length as usize / index_record_count
3951    }
3952}