Skip to main content

tzap_core/
metadata.rs

1use std::collections::{BTreeSet, HashMap, HashSet};
2
3use sha2::{Digest, Sha256};
4use unicode_normalization::UnicodeNormalization;
5
6use crate::format::FormatError;
7
8const TZIR_MAGIC: [u8; 4] = *b"TZIR";
9const TZIS_MAGIC: [u8; 4] = *b"TZIS";
10const TZDH_MAGIC: [u8; 4] = *b"TZDH";
11
12pub const INDEX_ROOT_LEN: usize = 160;
13pub const SHARD_ENTRY_LEN: usize = 52;
14pub const DIRECTORY_HINT_SHARD_ENTRY_LEN: usize = 56;
15pub const ENVELOPE_ENTRY_LEN: usize = 48;
16pub const FRAME_ENTRY_LEN: usize = 44;
17pub const INDEX_SHARD_HEADER_LEN: usize = 64;
18pub const FILE_ENTRY_LEN: usize = 56;
19pub const DIRECTORY_HINT_TABLE_LEN: usize = 72;
20pub const DIRECTORY_HINT_ENTRY_LEN: usize = 40;
21
22const FRAME_KNOWN_FLAGS: u32 = 0x0000_0003;
23const DEFAULT_MAX_HASH_COLLISION_SHARD_SCAN: usize = 16;
24const REED_SOLOMON_GF16_MAX_TOTAL_SHARDS: u64 = 65_535;
25const SHA256_EMPTY: [u8; 32] = [
26    0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, 0x24,
27    0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, 0xb8, 0x55,
28];
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct MetadataLimits {
32    pub block_size: u32,
33    pub max_path_length: u32,
34    pub max_hash_collision_shard_scan: usize,
35    pub max_shard_count: u32,
36    pub max_directory_hint_shards: u32,
37    pub max_files_per_index_shard: u32,
38    pub max_entries_per_directory_hint_shard: u64,
39    pub max_payload_data_shards: u16,
40    pub max_payload_parity_shards: u16,
41    pub max_index_data_shards: u16,
42    pub max_index_parity_shards: u16,
43    pub max_index_root_data_shards: u16,
44    pub max_index_root_parity_shards: u16,
45}
46
47impl Default for MetadataLimits {
48    fn default() -> Self {
49        Self {
50            block_size: 4096,
51            max_path_length: 4096,
52            max_hash_collision_shard_scan: DEFAULT_MAX_HASH_COLLISION_SHARD_SCAN,
53            max_shard_count: 1_000_000,
54            max_directory_hint_shards: 1_000_000,
55            max_files_per_index_shard: 1_000_000,
56            max_entries_per_directory_hint_shard: 1_000_000,
57            max_payload_data_shards: u16::MAX,
58            max_payload_parity_shards: u16::MAX,
59            max_index_data_shards: u16::MAX,
60            max_index_parity_shards: u16::MAX,
61            max_index_root_data_shards: u16::MAX,
62            max_index_root_parity_shards: u16::MAX,
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct IndexRootHeader {
69    pub version: u32,
70    pub shard_count: u32,
71    pub directory_hint_shard_count: u32,
72    pub frame_count: u64,
73    pub envelope_count: u64,
74    pub file_count: u64,
75    pub payload_block_count: u64,
76    pub tar_total_size: u64,
77    pub content_sha256: [u8; 32],
78    pub shard_table_offset: u64,
79    pub directory_hint_shard_table_offset: u64,
80    pub dictionary_first_block: u64,
81    pub dictionary_data_block_count: u32,
82    pub dictionary_parity_block_count: u32,
83    pub dictionary_encrypted_size: u32,
84    pub dictionary_decompressed_size: u32,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct IndexRoot {
89    pub header: IndexRootHeader,
90    pub shards: Vec<ShardEntry>,
91    pub directory_hint_shards: Vec<DirectoryHintShardEntry>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ShardEntry {
96    pub shard_index: u64,
97    pub first_block_index: u64,
98    pub data_block_count: u32,
99    pub parity_block_count: u32,
100    pub encrypted_size: u32,
101    pub decompressed_size: u32,
102    pub file_count: u32,
103    pub first_path_hash: [u8; 8],
104    pub last_path_hash: [u8; 8],
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct DirectoryHintShardEntry {
109    pub hint_shard_index: u64,
110    pub first_dir_hash: [u8; 8],
111    pub last_dir_hash: [u8; 8],
112    pub first_block_index: u64,
113    pub data_block_count: u32,
114    pub parity_block_count: u32,
115    pub encrypted_size: u32,
116    pub decompressed_size: u32,
117    pub entry_count: u64,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct EnvelopeEntry {
122    pub envelope_index: u64,
123    pub first_block_index: u64,
124    pub data_block_count: u32,
125    pub parity_block_count: u32,
126    pub encrypted_size: u32,
127    pub plaintext_size: u32,
128    pub first_frame_index: u64,
129    pub frame_count: u32,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct FrameEntry {
134    pub frame_index: u64,
135    pub envelope_index: u64,
136    pub offset_in_envelope: u32,
137    pub compressed_size: u32,
138    pub decompressed_size: u32,
139    pub flags: u32,
140    pub tar_stream_offset: u64,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct IndexShardHeader {
145    pub version: u32,
146    pub shard_index: u64,
147    pub file_count: u32,
148    pub frame_count: u32,
149    pub envelope_count: u32,
150    pub file_table_offset: u32,
151    pub frame_table_offset: u32,
152    pub envelope_table_offset: u32,
153    pub string_pool_offset: u32,
154    pub string_pool_size: u32,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct IndexShard {
159    pub header: IndexShardHeader,
160    pub files: Vec<FileEntry>,
161    pub frames: Vec<FrameEntry>,
162    pub envelopes: Vec<EnvelopeEntry>,
163    pub string_pool: Vec<u8>,
164    file_paths: Vec<Vec<u8>>,
165    file_tar_member_group_starts: Vec<u64>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FileEntry {
170    pub path_hash: [u8; 8],
171    pub path_offset: u32,
172    pub path_length: u32,
173    pub first_frame_index: u64,
174    pub frame_count: u32,
175    pub offset_in_first_frame_plaintext: u32,
176    pub tar_member_group_size: u64,
177    pub file_data_size: u64,
178    pub flags: u32,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct DirectoryHintTable {
183    pub header: DirectoryHintTableHeader,
184    pub entries: Vec<DirectoryHintEntry>,
185    pub shard_row_indexes: Vec<u32>,
186    pub string_pool: Vec<u8>,
187    entry_paths: Vec<Vec<u8>>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct DirectoryHintTableHeader {
192    pub version: u32,
193    pub hint_shard_index: u64,
194    pub entry_count: u64,
195    pub entry_table_offset: u64,
196    pub shard_list_offset: u64,
197    pub string_pool_offset: u64,
198    pub string_pool_size: u64,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct DirectoryHintEntry {
203    pub dir_hash: [u8; 8],
204    pub path_offset: u64,
205    pub path_length: u32,
206    pub shard_list_start_index: u32,
207    pub shard_count: u32,
208}
209
210impl IndexRoot {
211    pub fn parse(
212        bytes: &[u8],
213        has_dictionary: bool,
214        limits: MetadataLimits,
215    ) -> Result<Self, FormatError> {
216        let structure = "IndexRoot";
217        if bytes.len() < INDEX_ROOT_LEN {
218            return invalid(structure, "plaintext is shorter than fixed header");
219        }
220        expect_magic(structure, TZIR_MAGIC, read_array::<4>(bytes, 0, structure)?)?;
221        expect_zero(structure, slice(bytes, 128, 32, structure)?)?;
222
223        let header = IndexRootHeader {
224            version: read_u32(bytes, 4, structure)?,
225            shard_count: read_u32(bytes, 8, structure)?,
226            directory_hint_shard_count: read_u32(bytes, 12, structure)?,
227            frame_count: read_u64(bytes, 16, structure)?,
228            envelope_count: read_u64(bytes, 24, structure)?,
229            file_count: read_u64(bytes, 32, structure)?,
230            payload_block_count: read_u64(bytes, 40, structure)?,
231            tar_total_size: read_u64(bytes, 48, structure)?,
232            content_sha256: read_array::<32>(bytes, 56, structure)?,
233            shard_table_offset: read_u64(bytes, 88, structure)?,
234            directory_hint_shard_table_offset: read_u64(bytes, 96, structure)?,
235            dictionary_first_block: read_u64(bytes, 104, structure)?,
236            dictionary_data_block_count: read_u32(bytes, 112, structure)?,
237            dictionary_parity_block_count: read_u32(bytes, 116, structure)?,
238            dictionary_encrypted_size: read_u32(bytes, 120, structure)?,
239            dictionary_decompressed_size: read_u32(bytes, 124, structure)?,
240        };
241
242        if header.version != 1 {
243            return invalid(structure, "unsupported version");
244        }
245        if header.shard_count > limits.max_shard_count {
246            return invalid(structure, "shard count exceeds resource cap");
247        }
248        if header.directory_hint_shard_count > limits.max_directory_hint_shards {
249            return invalid(structure, "directory hint shard count exceeds resource cap");
250        }
251        validate_dictionary_fields(&header, has_dictionary, limits)?;
252
253        let mut cursor = INDEX_ROOT_LEN;
254        let shards = if header.shard_count == 0 {
255            if header.shard_table_offset != 0 {
256                return invalid(structure, "absent shard table has non-zero offset");
257            }
258            Vec::new()
259        } else {
260            expect_offset(structure, "shard table", header.shard_table_offset, cursor)?;
261            let count = to_usize(header.shard_count as u64, structure)?;
262            let bytes_len = checked_mul(count, SHARD_ENTRY_LEN, structure)?;
263            let table = slice(bytes, cursor, bytes_len, structure)?;
264            cursor = checked_add(cursor, bytes_len, structure)?;
265            parse_shard_entries(table, limits)?
266        };
267
268        let directory_hint_shards = if header.directory_hint_shard_count == 0 {
269            if header.directory_hint_shard_table_offset != 0 {
270                return invalid(
271                    structure,
272                    "absent directory hint shard table has non-zero offset",
273                );
274            }
275            Vec::new()
276        } else {
277            if header.shard_count == 0 {
278                return invalid(structure, "directory hints require at least one shard");
279            }
280            expect_offset(
281                structure,
282                "directory hint shard table",
283                header.directory_hint_shard_table_offset,
284                cursor,
285            )?;
286            let count = to_usize(header.directory_hint_shard_count as u64, structure)?;
287            let bytes_len = checked_mul(count, DIRECTORY_HINT_SHARD_ENTRY_LEN, structure)?;
288            let table = slice(bytes, cursor, bytes_len, structure)?;
289            cursor = checked_add(cursor, bytes_len, structure)?;
290            parse_directory_hint_shard_entries(table, limits)?
291        };
292
293        if bytes.len() != cursor {
294            return invalid(
295                structure,
296                "plaintext length does not match canonical cursor",
297            );
298        }
299        validate_index_root_totals(&header, &shards, has_dictionary)?;
300
301        Ok(Self {
302            header,
303            shards,
304            directory_hint_shards,
305        })
306    }
307
308    pub fn to_bytes(&self) -> Vec<u8> {
309        let mut header = self.header.clone();
310        header.shard_count = self.shards.len() as u32;
311        header.directory_hint_shard_count = self.directory_hint_shards.len() as u32;
312        header.shard_table_offset = if self.shards.is_empty() {
313            0
314        } else {
315            INDEX_ROOT_LEN as u64
316        };
317        header.directory_hint_shard_table_offset = if self.directory_hint_shards.is_empty() {
318            0
319        } else {
320            (INDEX_ROOT_LEN + self.shards.len() * SHARD_ENTRY_LEN) as u64
321        };
322
323        let mut bytes = Vec::with_capacity(
324            INDEX_ROOT_LEN
325                + self.shards.len() * SHARD_ENTRY_LEN
326                + self.directory_hint_shards.len() * DIRECTORY_HINT_SHARD_ENTRY_LEN,
327        );
328        bytes.extend_from_slice(&header.to_bytes());
329        for entry in &self.shards {
330            bytes.extend_from_slice(&entry.to_bytes());
331        }
332        for entry in &self.directory_hint_shards {
333            bytes.extend_from_slice(&entry.to_bytes());
334        }
335        bytes
336    }
337
338    pub fn candidate_shard_indexes_for_hash(
339        &self,
340        target_hash: [u8; 8],
341        scan_cap_per_direction: usize,
342    ) -> Result<Vec<usize>, FormatError> {
343        candidate_interval_indexes(
344            &self.shards,
345            target_hash,
346            scan_cap_per_direction,
347            |entry| entry.first_path_hash,
348            |entry| entry.last_path_hash,
349        )
350    }
351
352    pub fn candidate_shards_for_path(
353        &self,
354        normalized_path: &[u8],
355        limits: MetadataLimits,
356    ) -> Result<Vec<usize>, FormatError> {
357        self.candidate_shard_indexes_for_hash(
358            hash_prefix(normalized_path),
359            limits.max_hash_collision_shard_scan,
360        )
361    }
362}
363
364impl IndexRootHeader {
365    pub fn empty() -> Self {
366        Self {
367            version: 1,
368            shard_count: 0,
369            directory_hint_shard_count: 0,
370            frame_count: 0,
371            envelope_count: 0,
372            file_count: 0,
373            payload_block_count: 0,
374            tar_total_size: 0,
375            content_sha256: SHA256_EMPTY,
376            shard_table_offset: 0,
377            directory_hint_shard_table_offset: 0,
378            dictionary_first_block: 0,
379            dictionary_data_block_count: 0,
380            dictionary_parity_block_count: 0,
381            dictionary_encrypted_size: 0,
382            dictionary_decompressed_size: 0,
383        }
384    }
385
386    pub fn to_bytes(&self) -> [u8; INDEX_ROOT_LEN] {
387        let mut bytes = [0u8; INDEX_ROOT_LEN];
388        bytes[0..4].copy_from_slice(&TZIR_MAGIC);
389        write_u32(&mut bytes, 4, self.version);
390        write_u32(&mut bytes, 8, self.shard_count);
391        write_u32(&mut bytes, 12, self.directory_hint_shard_count);
392        write_u64(&mut bytes, 16, self.frame_count);
393        write_u64(&mut bytes, 24, self.envelope_count);
394        write_u64(&mut bytes, 32, self.file_count);
395        write_u64(&mut bytes, 40, self.payload_block_count);
396        write_u64(&mut bytes, 48, self.tar_total_size);
397        bytes[56..88].copy_from_slice(&self.content_sha256);
398        write_u64(&mut bytes, 88, self.shard_table_offset);
399        write_u64(&mut bytes, 96, self.directory_hint_shard_table_offset);
400        write_u64(&mut bytes, 104, self.dictionary_first_block);
401        write_u32(&mut bytes, 112, self.dictionary_data_block_count);
402        write_u32(&mut bytes, 116, self.dictionary_parity_block_count);
403        write_u32(&mut bytes, 120, self.dictionary_encrypted_size);
404        write_u32(&mut bytes, 124, self.dictionary_decompressed_size);
405        bytes
406    }
407}
408
409impl ShardEntry {
410    pub fn to_bytes(&self) -> [u8; SHARD_ENTRY_LEN] {
411        let mut bytes = [0u8; SHARD_ENTRY_LEN];
412        write_u64(&mut bytes, 0, self.shard_index);
413        write_u64(&mut bytes, 8, self.first_block_index);
414        write_u32(&mut bytes, 16, self.data_block_count);
415        write_u32(&mut bytes, 20, self.parity_block_count);
416        write_u32(&mut bytes, 24, self.encrypted_size);
417        write_u32(&mut bytes, 28, self.decompressed_size);
418        write_u32(&mut bytes, 32, self.file_count);
419        bytes[36..44].copy_from_slice(&self.first_path_hash);
420        bytes[44..52].copy_from_slice(&self.last_path_hash);
421        bytes
422    }
423}
424
425impl DirectoryHintShardEntry {
426    pub fn to_bytes(&self) -> [u8; DIRECTORY_HINT_SHARD_ENTRY_LEN] {
427        let mut bytes = [0u8; DIRECTORY_HINT_SHARD_ENTRY_LEN];
428        write_u64(&mut bytes, 0, self.hint_shard_index);
429        bytes[8..16].copy_from_slice(&self.first_dir_hash);
430        bytes[16..24].copy_from_slice(&self.last_dir_hash);
431        write_u64(&mut bytes, 24, self.first_block_index);
432        write_u32(&mut bytes, 32, self.data_block_count);
433        write_u32(&mut bytes, 36, self.parity_block_count);
434        write_u32(&mut bytes, 40, self.encrypted_size);
435        write_u32(&mut bytes, 44, self.decompressed_size);
436        write_u64(&mut bytes, 48, self.entry_count);
437        bytes
438    }
439}
440
441impl IndexShard {
442    pub fn parse(
443        bytes: &[u8],
444        locating_shard: &ShardEntry,
445        limits: MetadataLimits,
446    ) -> Result<Self, FormatError> {
447        let structure = "IndexShard";
448        if bytes.len() < INDEX_SHARD_HEADER_LEN {
449            return invalid(structure, "plaintext is shorter than fixed header");
450        }
451        expect_magic(structure, TZIS_MAGIC, read_array::<4>(bytes, 0, structure)?)?;
452        expect_zero(structure, slice(bytes, 48, 16, structure)?)?;
453
454        let header = IndexShardHeader {
455            version: read_u32(bytes, 4, structure)?,
456            shard_index: read_u64(bytes, 8, structure)?,
457            file_count: read_u32(bytes, 16, structure)?,
458            frame_count: read_u32(bytes, 20, structure)?,
459            envelope_count: read_u32(bytes, 24, structure)?,
460            file_table_offset: read_u32(bytes, 28, structure)?,
461            frame_table_offset: read_u32(bytes, 32, structure)?,
462            envelope_table_offset: read_u32(bytes, 36, structure)?,
463            string_pool_offset: read_u32(bytes, 40, structure)?,
464            string_pool_size: read_u32(bytes, 44, structure)?,
465        };
466
467        if header.version != 1 {
468            return invalid(structure, "unsupported version");
469        }
470        if header.file_count == 0 {
471            return invalid(structure, "index shard must contain at least one file");
472        }
473        if header.file_count > limits.max_files_per_index_shard {
474            return invalid(structure, "file count exceeds resource cap");
475        }
476        if header.shard_index != locating_shard.shard_index {
477            return invalid(structure, "shard index does not match locating ShardEntry");
478        }
479        if header.file_count != locating_shard.file_count {
480            return invalid(structure, "file count does not match locating ShardEntry");
481        }
482
483        let mut cursor = INDEX_SHARD_HEADER_LEN;
484        let files = parse_counted_table(
485            bytes,
486            CountedTableSpec {
487                structure,
488                name: "file table",
489                count: header.file_count as u64,
490                offset: header.file_table_offset as u64,
491                entry_len: FILE_ENTRY_LEN,
492                parse: parse_file_entry,
493            },
494            &mut cursor,
495        )?;
496        let frames = parse_counted_table(
497            bytes,
498            CountedTableSpec {
499                structure,
500                name: "frame table",
501                count: header.frame_count as u64,
502                offset: header.frame_table_offset as u64,
503                entry_len: FRAME_ENTRY_LEN,
504                parse: parse_frame_entry,
505            },
506            &mut cursor,
507        )?;
508        let envelopes = parse_counted_table(
509            bytes,
510            CountedTableSpec {
511                structure,
512                name: "envelope table",
513                count: header.envelope_count as u64,
514                offset: header.envelope_table_offset as u64,
515                entry_len: ENVELOPE_ENTRY_LEN,
516                parse: parse_envelope_entry,
517            },
518            &mut cursor,
519        )?;
520        let string_pool = if header.string_pool_size == 0 {
521            if header.string_pool_offset != 0 {
522                return invalid(structure, "absent string pool has non-zero offset");
523            }
524            Vec::new()
525        } else {
526            expect_offset(
527                structure,
528                "string pool",
529                header.string_pool_offset as u64,
530                cursor,
531            )?;
532            let len = header.string_pool_size as usize;
533            let pool = slice(bytes, cursor, len, structure)?.to_vec();
534            cursor = checked_add(cursor, len, structure)?;
535            pool
536        };
537        if bytes.len() != cursor {
538            return invalid(
539                structure,
540                "plaintext length does not match canonical cursor",
541            );
542        }
543
544        let (file_paths, file_tar_member_group_starts) = validate_index_shard_tables(
545            &files,
546            &frames,
547            &envelopes,
548            &string_pool,
549            locating_shard,
550            limits,
551        )?;
552
553        Ok(Self {
554            header,
555            files,
556            frames,
557            envelopes,
558            string_pool,
559            file_paths,
560            file_tar_member_group_starts,
561        })
562    }
563
564    pub fn to_bytes(&self) -> Vec<u8> {
565        let mut header = self.header.clone();
566        header.file_count = self.files.len() as u32;
567        header.frame_count = self.frames.len() as u32;
568        header.envelope_count = self.envelopes.len() as u32;
569
570        let mut cursor = INDEX_SHARD_HEADER_LEN;
571        header.file_table_offset = table_offset(self.files.len(), cursor);
572        cursor += self.files.len() * FILE_ENTRY_LEN;
573        header.frame_table_offset = table_offset(self.frames.len(), cursor);
574        cursor += self.frames.len() * FRAME_ENTRY_LEN;
575        header.envelope_table_offset = table_offset(self.envelopes.len(), cursor);
576        cursor += self.envelopes.len() * ENVELOPE_ENTRY_LEN;
577        header.string_pool_size = self.string_pool.len() as u32;
578        header.string_pool_offset = table_offset(self.string_pool.len(), cursor);
579
580        let mut bytes = Vec::with_capacity(cursor + self.string_pool.len());
581        bytes.extend_from_slice(&header.to_bytes());
582        for entry in &self.files {
583            bytes.extend_from_slice(&entry.to_bytes());
584        }
585        for entry in &self.frames {
586            bytes.extend_from_slice(&entry.to_bytes());
587        }
588        for entry in &self.envelopes {
589            bytes.extend_from_slice(&entry.to_bytes());
590        }
591        bytes.extend_from_slice(&self.string_pool);
592        bytes
593    }
594
595    pub fn file_path(&self, file_index: usize) -> Option<&[u8]> {
596        self.file_paths.get(file_index).map(Vec::as_slice)
597    }
598
599    pub fn tar_member_group_start(&self, file_index: usize) -> Option<u64> {
600        self.file_tar_member_group_starts.get(file_index).copied()
601    }
602
603    pub fn lookup_file_index(&self, normalized_path: &[u8]) -> Option<usize> {
604        let target_hash = hash_prefix(normalized_path);
605        let lower = self.lower_bound_file_key(target_hash, normalized_path);
606
607        let mut best = None;
608        for idx in lower..self.files.len() {
609            let file = &self.files[idx];
610            if file.path_hash != target_hash || self.file_paths[idx].as_slice() != normalized_path {
611                break;
612            }
613            best = Some(idx);
614        }
615        best
616    }
617
618    fn lower_bound_file_key(&self, target_hash: [u8; 8], target_path: &[u8]) -> usize {
619        let mut low = 0usize;
620        let mut high = self.files.len();
621        while low < high {
622            let mid = low + (high - low) / 2;
623            let key_is_less = self.files[mid].path_hash < target_hash
624                || (self.files[mid].path_hash == target_hash
625                    && self.file_paths[mid].as_slice() < target_path);
626            if key_is_less {
627                low = mid + 1;
628            } else {
629                high = mid;
630            }
631        }
632        low
633    }
634}
635
636impl IndexShardHeader {
637    pub fn to_bytes(&self) -> [u8; INDEX_SHARD_HEADER_LEN] {
638        let mut bytes = [0u8; INDEX_SHARD_HEADER_LEN];
639        bytes[0..4].copy_from_slice(&TZIS_MAGIC);
640        write_u32(&mut bytes, 4, self.version);
641        write_u64(&mut bytes, 8, self.shard_index);
642        write_u32(&mut bytes, 16, self.file_count);
643        write_u32(&mut bytes, 20, self.frame_count);
644        write_u32(&mut bytes, 24, self.envelope_count);
645        write_u32(&mut bytes, 28, self.file_table_offset);
646        write_u32(&mut bytes, 32, self.frame_table_offset);
647        write_u32(&mut bytes, 36, self.envelope_table_offset);
648        write_u32(&mut bytes, 40, self.string_pool_offset);
649        write_u32(&mut bytes, 44, self.string_pool_size);
650        bytes
651    }
652}
653
654impl FileEntry {
655    pub fn to_bytes(&self) -> [u8; FILE_ENTRY_LEN] {
656        let mut bytes = [0u8; FILE_ENTRY_LEN];
657        bytes[0..8].copy_from_slice(&self.path_hash);
658        write_u32(&mut bytes, 8, self.path_offset);
659        write_u32(&mut bytes, 12, self.path_length);
660        write_u64(&mut bytes, 16, self.first_frame_index);
661        write_u32(&mut bytes, 24, self.frame_count);
662        write_u32(&mut bytes, 28, self.offset_in_first_frame_plaintext);
663        write_u64(&mut bytes, 32, self.tar_member_group_size);
664        write_u64(&mut bytes, 40, self.file_data_size);
665        write_u32(&mut bytes, 48, self.flags);
666        bytes
667    }
668}
669
670impl FrameEntry {
671    pub fn to_bytes(&self) -> [u8; FRAME_ENTRY_LEN] {
672        let mut bytes = [0u8; FRAME_ENTRY_LEN];
673        write_u64(&mut bytes, 0, self.frame_index);
674        write_u64(&mut bytes, 8, self.envelope_index);
675        write_u32(&mut bytes, 16, self.offset_in_envelope);
676        write_u32(&mut bytes, 20, self.compressed_size);
677        write_u32(&mut bytes, 24, self.decompressed_size);
678        write_u32(&mut bytes, 28, self.flags);
679        write_u64(&mut bytes, 32, self.tar_stream_offset);
680        bytes
681    }
682}
683
684impl EnvelopeEntry {
685    pub fn to_bytes(&self) -> [u8; ENVELOPE_ENTRY_LEN] {
686        let mut bytes = [0u8; ENVELOPE_ENTRY_LEN];
687        write_u64(&mut bytes, 0, self.envelope_index);
688        write_u64(&mut bytes, 8, self.first_block_index);
689        write_u32(&mut bytes, 16, self.data_block_count);
690        write_u32(&mut bytes, 20, self.parity_block_count);
691        write_u32(&mut bytes, 24, self.encrypted_size);
692        write_u32(&mut bytes, 28, self.plaintext_size);
693        write_u64(&mut bytes, 32, self.first_frame_index);
694        write_u32(&mut bytes, 40, self.frame_count);
695        bytes
696    }
697}
698
699impl DirectoryHintTable {
700    pub fn parse(
701        bytes: &[u8],
702        locating_shard: &DirectoryHintShardEntry,
703        index_root_shard_count: u32,
704        limits: MetadataLimits,
705    ) -> Result<Self, FormatError> {
706        let structure = "DirectoryHintTable";
707        if bytes.len() < DIRECTORY_HINT_TABLE_LEN {
708            return invalid(structure, "plaintext is shorter than fixed header");
709        }
710        expect_magic(structure, TZDH_MAGIC, read_array::<4>(bytes, 0, structure)?)?;
711        expect_zero(structure, slice(bytes, 56, 16, structure)?)?;
712
713        let header = DirectoryHintTableHeader {
714            version: read_u32(bytes, 4, structure)?,
715            hint_shard_index: read_u64(bytes, 8, structure)?,
716            entry_count: read_u64(bytes, 16, structure)?,
717            entry_table_offset: read_u64(bytes, 24, structure)?,
718            shard_list_offset: read_u64(bytes, 32, structure)?,
719            string_pool_offset: read_u64(bytes, 40, structure)?,
720            string_pool_size: read_u64(bytes, 48, structure)?,
721        };
722        if header.version != 1 {
723            return invalid(structure, "unsupported version");
724        }
725        if header.hint_shard_index != locating_shard.hint_shard_index {
726            return invalid(
727                structure,
728                "hint shard index does not match locating DirectoryHintShardEntry",
729            );
730        }
731        if header.entry_count != locating_shard.entry_count {
732            return invalid(
733                structure,
734                "entry count does not match locating DirectoryHintShardEntry",
735            );
736        }
737        if header.entry_count == 0 {
738            return invalid(structure, "located directory hint shard is empty");
739        }
740        if header.entry_count > limits.max_entries_per_directory_hint_shard {
741            return invalid(structure, "entry count exceeds resource cap");
742        }
743
744        let entry_count = to_usize(header.entry_count, structure)?;
745        expect_offset(
746            structure,
747            "entry table",
748            header.entry_table_offset,
749            DIRECTORY_HINT_TABLE_LEN,
750        )?;
751        let entry_bytes_len = checked_mul(entry_count, DIRECTORY_HINT_ENTRY_LEN, structure)?;
752        let entries_end = checked_add(DIRECTORY_HINT_TABLE_LEN, entry_bytes_len, structure)?;
753        expect_offset(
754            structure,
755            "shard list",
756            header.shard_list_offset,
757            entries_end,
758        )?;
759        if header.shard_list_offset % 4 != 0 {
760            return invalid(structure, "shard list is not 4-byte aligned");
761        }
762
763        let entry_bytes = slice(bytes, DIRECTORY_HINT_TABLE_LEN, entry_bytes_len, structure)?;
764        let entries = parse_directory_hint_entries(entry_bytes)?;
765        let shard_list_len = validate_directory_hint_entries(
766            &entries,
767            bytes,
768            &header,
769            locating_shard,
770            index_root_shard_count,
771        )?;
772        let shard_list_offset = to_usize(header.shard_list_offset, structure)?;
773        let shard_list_bytes_len = checked_mul(shard_list_len, 4, structure)?;
774        let shard_list_end = checked_add(shard_list_offset, shard_list_bytes_len, structure)?;
775        let shard_list_bytes = slice(bytes, shard_list_offset, shard_list_bytes_len, structure)?;
776        let shard_row_indexes = parse_u32_array(shard_list_bytes, structure)?;
777
778        let string_pool = if header.string_pool_size == 0 {
779            if header.string_pool_offset != 0 {
780                return invalid(structure, "absent string pool has non-zero offset");
781            }
782            Vec::new()
783        } else {
784            expect_offset(
785                structure,
786                "string pool",
787                header.string_pool_offset,
788                shard_list_end,
789            )?;
790            let offset = to_usize(header.string_pool_offset, structure)?;
791            let size = to_usize(header.string_pool_size, structure)?;
792            slice(bytes, offset, size, structure)?.to_vec()
793        };
794        let final_cursor = if header.string_pool_size == 0 {
795            shard_list_end
796        } else {
797            checked_add(
798                to_usize(header.string_pool_offset, structure)?,
799                to_usize(header.string_pool_size, structure)?,
800                structure,
801            )?
802        };
803        if bytes.len() != final_cursor {
804            return invalid(
805                structure,
806                "plaintext length does not match canonical cursor",
807            );
808        }
809
810        let entry_paths = validate_directory_hint_paths_and_lists(
811            &entries,
812            &shard_row_indexes,
813            &string_pool,
814            locating_shard,
815            index_root_shard_count,
816            limits.max_path_length,
817        )?;
818
819        Ok(Self {
820            header,
821            entries,
822            shard_row_indexes,
823            string_pool,
824            entry_paths,
825        })
826    }
827
828    pub fn to_bytes(&self) -> Vec<u8> {
829        let mut header = self.header.clone();
830        header.entry_count = self.entries.len() as u64;
831        header.entry_table_offset = if self.entries.is_empty() {
832            0
833        } else {
834            DIRECTORY_HINT_TABLE_LEN as u64
835        };
836        header.shard_list_offset = if self.entries.is_empty() {
837            0
838        } else {
839            (DIRECTORY_HINT_TABLE_LEN + self.entries.len() * DIRECTORY_HINT_ENTRY_LEN) as u64
840        };
841        header.string_pool_size = self.string_pool.len() as u64;
842        header.string_pool_offset = if self.string_pool.is_empty() {
843            0
844        } else {
845            header.shard_list_offset + (self.shard_row_indexes.len() as u64) * 4
846        };
847
848        let mut bytes = Vec::with_capacity(
849            DIRECTORY_HINT_TABLE_LEN
850                + self.entries.len() * DIRECTORY_HINT_ENTRY_LEN
851                + self.shard_row_indexes.len() * 4
852                + self.string_pool.len(),
853        );
854        bytes.extend_from_slice(&header.to_bytes());
855        for entry in &self.entries {
856            bytes.extend_from_slice(&entry.to_bytes());
857        }
858        if !self.entries.is_empty() {
859            for row in &self.shard_row_indexes {
860                let mut raw = [0u8; 4];
861                write_u32(&mut raw, 0, *row);
862                bytes.extend_from_slice(&raw);
863            }
864        }
865        bytes.extend_from_slice(&self.string_pool);
866        bytes
867    }
868
869    pub fn entry_path(&self, entry_index: usize) -> Option<&[u8]> {
870        self.entry_paths.get(entry_index).map(Vec::as_slice)
871    }
872
873    pub fn lookup_directory_index(&self, normalized_dir_path: &[u8]) -> Option<usize> {
874        let target_hash = hash_prefix(normalized_dir_path);
875        let lower = self.lower_bound_directory_key(target_hash, normalized_dir_path);
876        let entry = self.entries.get(lower)?;
877        if entry.dir_hash == target_hash
878            && self.entry_paths[lower].as_slice() == normalized_dir_path
879        {
880            Some(lower)
881        } else {
882            None
883        }
884    }
885
886    fn lower_bound_directory_key(&self, target_hash: [u8; 8], target_path: &[u8]) -> usize {
887        let mut low = 0usize;
888        let mut high = self.entries.len();
889        while low < high {
890            let mid = low + (high - low) / 2;
891            let key_is_less = self.entries[mid].dir_hash < target_hash
892                || (self.entries[mid].dir_hash == target_hash
893                    && self.entry_paths[mid].as_slice() < target_path);
894            if key_is_less {
895                low = mid + 1;
896            } else {
897                high = mid;
898            }
899        }
900        low
901    }
902
903    pub fn shard_rows_for_entry(&self, entry_index: usize) -> Option<&[u32]> {
904        let entry = self.entries.get(entry_index)?;
905        let start = entry.shard_list_start_index as usize;
906        let end = start.checked_add(entry.shard_count as usize)?;
907        self.shard_row_indexes.get(start..end)
908    }
909}
910
911impl DirectoryHintTableHeader {
912    pub fn to_bytes(&self) -> [u8; DIRECTORY_HINT_TABLE_LEN] {
913        let mut bytes = [0u8; DIRECTORY_HINT_TABLE_LEN];
914        bytes[0..4].copy_from_slice(&TZDH_MAGIC);
915        write_u32(&mut bytes, 4, self.version);
916        write_u64(&mut bytes, 8, self.hint_shard_index);
917        write_u64(&mut bytes, 16, self.entry_count);
918        write_u64(&mut bytes, 24, self.entry_table_offset);
919        write_u64(&mut bytes, 32, self.shard_list_offset);
920        write_u64(&mut bytes, 40, self.string_pool_offset);
921        write_u64(&mut bytes, 48, self.string_pool_size);
922        bytes
923    }
924}
925
926impl DirectoryHintEntry {
927    pub fn to_bytes(&self) -> [u8; DIRECTORY_HINT_ENTRY_LEN] {
928        let mut bytes = [0u8; DIRECTORY_HINT_ENTRY_LEN];
929        bytes[0..8].copy_from_slice(&self.dir_hash);
930        write_u64(&mut bytes, 8, self.path_offset);
931        write_u32(&mut bytes, 16, self.path_length);
932        write_u32(&mut bytes, 24, self.shard_list_start_index);
933        write_u32(&mut bytes, 28, self.shard_count);
934        bytes
935    }
936}
937
938pub fn hash_prefix(bytes: &[u8]) -> [u8; 8] {
939    let digest = Sha256::digest(bytes);
940    let mut out = [0u8; 8];
941    out.copy_from_slice(&digest[..8]);
942    out
943}
944
945pub fn normalize_lookup_file_path(
946    path: &str,
947    max_path_length: u32,
948) -> Result<Vec<u8>, FormatError> {
949    let normalized = path.nfc().collect::<String>();
950    validate_file_path_bytes(normalized.as_bytes(), max_path_length)?;
951    Ok(normalized.into_bytes())
952}
953
954pub fn normalize_lookup_directory_path(
955    path: &str,
956    max_path_length: u32,
957) -> Result<Vec<u8>, FormatError> {
958    let trimmed = path.strip_suffix('/').unwrap_or(path);
959    let normalized = trimmed.nfc().collect::<String>();
960    validate_directory_path_bytes(normalized.as_bytes(), max_path_length)?;
961    Ok(normalized.into_bytes())
962}
963
964pub fn is_directory_ancestor(directory_path: &[u8], file_path: &[u8]) -> bool {
965    if directory_path.is_empty() {
966        return true;
967    }
968    file_path.len() > directory_path.len()
969        && file_path.starts_with(directory_path)
970        && file_path[directory_path.len()] == b'/'
971}
972
973fn parse_shard_entries(
974    bytes: &[u8],
975    limits: MetadataLimits,
976) -> Result<Vec<ShardEntry>, FormatError> {
977    let mut entries = Vec::with_capacity(bytes.len() / SHARD_ENTRY_LEN);
978    let mut seen_indexes = HashSet::new();
979    for chunk in bytes.chunks_exact(SHARD_ENTRY_LEN) {
980        let entry = ShardEntry {
981            shard_index: read_u64(chunk, 0, "ShardEntry")?,
982            first_block_index: read_u64(chunk, 8, "ShardEntry")?,
983            data_block_count: read_u32(chunk, 16, "ShardEntry")?,
984            parity_block_count: read_u32(chunk, 20, "ShardEntry")?,
985            encrypted_size: read_u32(chunk, 24, "ShardEntry")?,
986            decompressed_size: read_u32(chunk, 28, "ShardEntry")?,
987            file_count: read_u32(chunk, 32, "ShardEntry")?,
988            first_path_hash: read_array::<8>(chunk, 36, "ShardEntry")?,
989            last_path_hash: read_array::<8>(chunk, 44, "ShardEntry")?,
990        };
991        if entry.file_count == 0 {
992            return invalid("ShardEntry", "file count is zero");
993        }
994        if entry.decompressed_size == 0 {
995            return invalid("ShardEntry", "decompressed size is zero");
996        }
997        validate_encrypted_extent(
998            "ShardEntry",
999            entry.data_block_count,
1000            entry.encrypted_size,
1001            limits.block_size,
1002        )?;
1003        validate_fec_class_extent(
1004            "ShardEntry",
1005            entry.data_block_count,
1006            entry.parity_block_count,
1007            limits.max_index_data_shards,
1008            limits.max_index_parity_shards,
1009        )?;
1010        if entry.first_path_hash > entry.last_path_hash {
1011            return invalid("ShardEntry", "first hash is greater than last hash");
1012        }
1013        if !seen_indexes.insert(entry.shard_index) {
1014            return invalid("ShardEntry", "duplicate shard index");
1015        }
1016        if let Some(previous) = entries.last() {
1017            let previous: &ShardEntry = previous;
1018            if shard_entry_sort_key(previous) >= shard_entry_sort_key(&entry) {
1019                return invalid("IndexRoot", "ShardEntry rows are not sorted");
1020            }
1021            if previous.last_path_hash > entry.first_path_hash {
1022                return invalid("IndexRoot", "ShardEntry hash ranges overlap out of order");
1023            }
1024        }
1025        entries.push(entry);
1026    }
1027    Ok(entries)
1028}
1029
1030fn parse_directory_hint_shard_entries(
1031    bytes: &[u8],
1032    limits: MetadataLimits,
1033) -> Result<Vec<DirectoryHintShardEntry>, FormatError> {
1034    let mut entries = Vec::with_capacity(bytes.len() / DIRECTORY_HINT_SHARD_ENTRY_LEN);
1035    let mut seen_indexes = HashSet::new();
1036    for chunk in bytes.chunks_exact(DIRECTORY_HINT_SHARD_ENTRY_LEN) {
1037        let entry = DirectoryHintShardEntry {
1038            hint_shard_index: read_u64(chunk, 0, "DirectoryHintShardEntry")?,
1039            first_dir_hash: read_array::<8>(chunk, 8, "DirectoryHintShardEntry")?,
1040            last_dir_hash: read_array::<8>(chunk, 16, "DirectoryHintShardEntry")?,
1041            first_block_index: read_u64(chunk, 24, "DirectoryHintShardEntry")?,
1042            data_block_count: read_u32(chunk, 32, "DirectoryHintShardEntry")?,
1043            parity_block_count: read_u32(chunk, 36, "DirectoryHintShardEntry")?,
1044            encrypted_size: read_u32(chunk, 40, "DirectoryHintShardEntry")?,
1045            decompressed_size: read_u32(chunk, 44, "DirectoryHintShardEntry")?,
1046            entry_count: read_u64(chunk, 48, "DirectoryHintShardEntry")?,
1047        };
1048        if entry.entry_count == 0 {
1049            return invalid("DirectoryHintShardEntry", "entry count is zero");
1050        }
1051        if entry.decompressed_size == 0 {
1052            return invalid("DirectoryHintShardEntry", "decompressed size is zero");
1053        }
1054        validate_encrypted_extent(
1055            "DirectoryHintShardEntry",
1056            entry.data_block_count,
1057            entry.encrypted_size,
1058            limits.block_size,
1059        )?;
1060        validate_fec_class_extent(
1061            "DirectoryHintShardEntry",
1062            entry.data_block_count,
1063            entry.parity_block_count,
1064            limits.max_index_data_shards,
1065            limits.max_index_parity_shards,
1066        )?;
1067        if entry.first_dir_hash > entry.last_dir_hash {
1068            return invalid(
1069                "DirectoryHintShardEntry",
1070                "first hash is greater than last hash",
1071            );
1072        }
1073        if !seen_indexes.insert(entry.hint_shard_index) {
1074            return invalid("DirectoryHintShardEntry", "duplicate hint shard index");
1075        }
1076        if let Some(previous) = entries.last() {
1077            let previous: &DirectoryHintShardEntry = previous;
1078            if directory_hint_shard_sort_key(previous) >= directory_hint_shard_sort_key(&entry) {
1079                return invalid("IndexRoot", "DirectoryHintShardEntry rows are not sorted");
1080            }
1081            if previous.last_dir_hash > entry.first_dir_hash {
1082                return invalid(
1083                    "IndexRoot",
1084                    "DirectoryHintShardEntry hash ranges overlap out of order",
1085                );
1086            }
1087        }
1088        entries.push(entry);
1089    }
1090    Ok(entries)
1091}
1092
1093fn parse_file_entry(bytes: &[u8]) -> Result<FileEntry, FormatError> {
1094    expect_zero("FileEntry", slice(bytes, 52, 4, "FileEntry")?)?;
1095    Ok(FileEntry {
1096        path_hash: read_array::<8>(bytes, 0, "FileEntry")?,
1097        path_offset: read_u32(bytes, 8, "FileEntry")?,
1098        path_length: read_u32(bytes, 12, "FileEntry")?,
1099        first_frame_index: read_u64(bytes, 16, "FileEntry")?,
1100        frame_count: read_u32(bytes, 24, "FileEntry")?,
1101        offset_in_first_frame_plaintext: read_u32(bytes, 28, "FileEntry")?,
1102        tar_member_group_size: read_u64(bytes, 32, "FileEntry")?,
1103        file_data_size: read_u64(bytes, 40, "FileEntry")?,
1104        flags: read_u32(bytes, 48, "FileEntry")?,
1105    })
1106}
1107
1108fn parse_frame_entry(bytes: &[u8]) -> Result<FrameEntry, FormatError> {
1109    expect_zero("FrameEntry", slice(bytes, 40, 4, "FrameEntry")?)?;
1110    Ok(FrameEntry {
1111        frame_index: read_u64(bytes, 0, "FrameEntry")?,
1112        envelope_index: read_u64(bytes, 8, "FrameEntry")?,
1113        offset_in_envelope: read_u32(bytes, 16, "FrameEntry")?,
1114        compressed_size: read_u32(bytes, 20, "FrameEntry")?,
1115        decompressed_size: read_u32(bytes, 24, "FrameEntry")?,
1116        flags: read_u32(bytes, 28, "FrameEntry")?,
1117        tar_stream_offset: read_u64(bytes, 32, "FrameEntry")?,
1118    })
1119}
1120
1121fn parse_envelope_entry(bytes: &[u8]) -> Result<EnvelopeEntry, FormatError> {
1122    expect_zero("EnvelopeEntry", slice(bytes, 44, 4, "EnvelopeEntry")?)?;
1123    Ok(EnvelopeEntry {
1124        envelope_index: read_u64(bytes, 0, "EnvelopeEntry")?,
1125        first_block_index: read_u64(bytes, 8, "EnvelopeEntry")?,
1126        data_block_count: read_u32(bytes, 16, "EnvelopeEntry")?,
1127        parity_block_count: read_u32(bytes, 20, "EnvelopeEntry")?,
1128        encrypted_size: read_u32(bytes, 24, "EnvelopeEntry")?,
1129        plaintext_size: read_u32(bytes, 28, "EnvelopeEntry")?,
1130        first_frame_index: read_u64(bytes, 32, "EnvelopeEntry")?,
1131        frame_count: read_u32(bytes, 40, "EnvelopeEntry")?,
1132    })
1133}
1134
1135fn parse_directory_hint_entries(bytes: &[u8]) -> Result<Vec<DirectoryHintEntry>, FormatError> {
1136    let mut entries = Vec::with_capacity(bytes.len() / DIRECTORY_HINT_ENTRY_LEN);
1137    for chunk in bytes.chunks_exact(DIRECTORY_HINT_ENTRY_LEN) {
1138        expect_zero(
1139            "DirectoryHintEntry",
1140            slice(chunk, 20, 4, "DirectoryHintEntry")?,
1141        )?;
1142        expect_zero(
1143            "DirectoryHintEntry",
1144            slice(chunk, 32, 8, "DirectoryHintEntry")?,
1145        )?;
1146        entries.push(DirectoryHintEntry {
1147            dir_hash: read_array::<8>(chunk, 0, "DirectoryHintEntry")?,
1148            path_offset: read_u64(chunk, 8, "DirectoryHintEntry")?,
1149            path_length: read_u32(chunk, 16, "DirectoryHintEntry")?,
1150            shard_list_start_index: read_u32(chunk, 24, "DirectoryHintEntry")?,
1151            shard_count: read_u32(chunk, 28, "DirectoryHintEntry")?,
1152        });
1153    }
1154    Ok(entries)
1155}
1156
1157fn validate_index_root_totals(
1158    header: &IndexRootHeader,
1159    shards: &[ShardEntry],
1160    has_dictionary: bool,
1161) -> Result<(), FormatError> {
1162    if shards.is_empty() {
1163        if header.file_count != 0
1164            || header.frame_count != 0
1165            || header.envelope_count != 0
1166            || header.payload_block_count != 0
1167            || header.tar_total_size != 0
1168        {
1169            return invalid(
1170                "IndexRoot",
1171                "empty shard table has non-empty archive totals",
1172            );
1173        }
1174        if header.content_sha256 != SHA256_EMPTY {
1175            return invalid(
1176                "IndexRoot",
1177                "empty archive content hash is not SHA-256(empty)",
1178            );
1179        }
1180        if has_dictionary || !index_root_dictionary_fields_are_zero(header) {
1181            return invalid("IndexRoot", "empty archive cannot use dictionary");
1182        }
1183        return Ok(());
1184    }
1185
1186    let mut sum = 0u64;
1187    for shard in shards {
1188        sum = sum.checked_add(shard.file_count as u64).ok_or(
1189            FormatError::MetadataArithmeticOverflow {
1190                structure: "IndexRoot",
1191            },
1192        )?;
1193    }
1194    if sum != header.file_count {
1195        return invalid(
1196            "IndexRoot",
1197            "file_count does not equal sum of ShardEntry rows",
1198        );
1199    }
1200    Ok(())
1201}
1202
1203fn validate_dictionary_fields(
1204    header: &IndexRootHeader,
1205    has_dictionary: bool,
1206    limits: MetadataLimits,
1207) -> Result<(), FormatError> {
1208    if !has_dictionary {
1209        if !index_root_dictionary_fields_are_zero(header) {
1210            return invalid(
1211                "IndexRoot",
1212                "dictionary fields are non-zero while has_dictionary is false",
1213            );
1214        }
1215        return Ok(());
1216    }
1217
1218    if header.dictionary_data_block_count == 0 {
1219        return invalid(
1220            "IndexRoot",
1221            "dictionary data block count is zero while has_dictionary is true",
1222        );
1223    }
1224    if header.dictionary_first_block == 0
1225        || header.dictionary_encrypted_size == 0
1226        || header.dictionary_decompressed_size == 0
1227    {
1228        return invalid("IndexRoot", "required dictionary field is zero");
1229    }
1230    validate_encrypted_extent(
1231        "IndexRoot.dictionary",
1232        header.dictionary_data_block_count,
1233        header.dictionary_encrypted_size,
1234        limits.block_size,
1235    )?;
1236    validate_fec_class_extent(
1237        "IndexRoot.dictionary",
1238        header.dictionary_data_block_count,
1239        header.dictionary_parity_block_count,
1240        limits.max_index_root_data_shards,
1241        limits.max_index_root_parity_shards,
1242    )
1243}
1244
1245fn index_root_dictionary_fields_are_zero(header: &IndexRootHeader) -> bool {
1246    header.dictionary_first_block == 0
1247        && header.dictionary_data_block_count == 0
1248        && header.dictionary_parity_block_count == 0
1249        && header.dictionary_encrypted_size == 0
1250        && header.dictionary_decompressed_size == 0
1251}
1252
1253fn validate_index_shard_tables(
1254    files: &[FileEntry],
1255    frames: &[FrameEntry],
1256    envelopes: &[EnvelopeEntry],
1257    string_pool: &[u8],
1258    locating_shard: &ShardEntry,
1259    limits: MetadataLimits,
1260) -> Result<(Vec<Vec<u8>>, Vec<u64>), FormatError> {
1261    validate_frame_table(frames)?;
1262    validate_envelope_table(envelopes, limits)?;
1263
1264    let frame_by_index = frames
1265        .iter()
1266        .enumerate()
1267        .map(|(idx, frame)| (frame.frame_index, idx))
1268        .collect::<HashMap<_, _>>();
1269    let envelope_by_index = envelopes
1270        .iter()
1271        .enumerate()
1272        .map(|(idx, envelope)| (envelope.envelope_index, idx))
1273        .collect::<HashMap<_, _>>();
1274
1275    let mut paths = Vec::with_capacity(files.len());
1276    let mut starts = Vec::with_capacity(files.len());
1277    let mut required_frames = BTreeSet::new();
1278
1279    for file in files {
1280        if file.flags != 0 {
1281            return invalid("FileEntry", "reserved flags are non-zero");
1282        }
1283        if file.path_length == 0 {
1284            return invalid("FileEntry", "path length is zero");
1285        }
1286        if file.path_length > limits.max_path_length {
1287            return invalid("FileEntry", "path length exceeds configured maximum");
1288        }
1289        if file.frame_count == 0 {
1290            return invalid("FileEntry", "frame count is zero");
1291        }
1292        if file.tar_member_group_size < 512 {
1293            return invalid(
1294                "FileEntry",
1295                "tar member group is smaller than one tar record",
1296            );
1297        }
1298        if file.path_hash < locating_shard.first_path_hash
1299            || file.path_hash > locating_shard.last_path_hash
1300        {
1301            return invalid(
1302                "FileEntry",
1303                "path hash is outside locating ShardEntry bounds",
1304            );
1305        }
1306
1307        let path = string_slice(
1308            string_pool,
1309            file.path_offset as u64,
1310            file.path_length as u64,
1311            "FileEntry",
1312        )?;
1313        validate_file_path_bytes(path, limits.max_path_length)?;
1314        if hash_prefix(path) != file.path_hash {
1315            return invalid("FileEntry", "path hash does not match string-pool path");
1316        }
1317
1318        let first_frame = frame_for_file(file, &frame_by_index, frames, file.first_frame_index)?;
1319        let tar_member_group_start = first_frame
1320            .tar_stream_offset
1321            .checked_add(file.offset_in_first_frame_plaintext as u64)
1322            .ok_or(FormatError::MetadataArithmeticOverflow {
1323                structure: "FileEntry",
1324            })?;
1325        validate_file_frame_range(file, frames, &frame_by_index)?;
1326        for offset in 0..file.frame_count as u64 {
1327            let index = file.first_frame_index.checked_add(offset).ok_or(
1328                FormatError::MetadataArithmeticOverflow {
1329                    structure: "FileEntry",
1330                },
1331            )?;
1332            required_frames.insert(index);
1333        }
1334        paths.push(path.to_vec());
1335        starts.push(tar_member_group_start);
1336    }
1337
1338    validate_file_order(files, &paths, &starts)?;
1339    if required_frames.len() != frames.len()
1340        || frames
1341            .iter()
1342            .any(|frame| !required_frames.contains(&frame.frame_index))
1343    {
1344        return invalid(
1345            "IndexShard",
1346            "FrameEntry table is not the exact set referenced by FileEntry rows",
1347        );
1348    }
1349
1350    let mut required_envelopes = BTreeSet::new();
1351    for frame in frames {
1352        let envelope = envelope_by_index
1353            .get(&frame.envelope_index)
1354            .and_then(|idx| envelopes.get(*idx))
1355            .ok_or(FormatError::InvalidMetadata {
1356                structure: "FrameEntry",
1357                reason: "referenced EnvelopeEntry is missing",
1358            })?;
1359        validate_frame_envelope_binding(frame, envelope)?;
1360        required_envelopes.insert(frame.envelope_index);
1361    }
1362    if required_envelopes.len() != envelopes.len()
1363        || envelopes
1364            .iter()
1365            .any(|entry| !required_envelopes.contains(&entry.envelope_index))
1366    {
1367        return invalid(
1368            "IndexShard",
1369            "EnvelopeEntry table is not the exact set referenced by FrameEntry rows",
1370        );
1371    }
1372    validate_frame_slices_by_envelope(frames, envelopes)?;
1373
1374    if let Some(first) = files.first() {
1375        if first.path_hash != locating_shard.first_path_hash {
1376            return invalid(
1377                "IndexShard",
1378                "first FileEntry hash does not match ShardEntry",
1379            );
1380        }
1381    }
1382    if let Some(last) = files.last() {
1383        if last.path_hash != locating_shard.last_path_hash {
1384            return invalid(
1385                "IndexShard",
1386                "last FileEntry hash does not match ShardEntry",
1387            );
1388        }
1389    }
1390
1391    Ok((paths, starts))
1392}
1393
1394fn validate_frame_table(frames: &[FrameEntry]) -> Result<(), FormatError> {
1395    for frame in frames {
1396        if frame.compressed_size == 0 || frame.decompressed_size == 0 {
1397            return invalid("FrameEntry", "frame sizes must be non-zero");
1398        }
1399        if frame.flags & !FRAME_KNOWN_FLAGS != 0 {
1400            return invalid("FrameEntry", "reserved flag bits are non-zero");
1401        }
1402    }
1403    for pair in frames.windows(2) {
1404        let previous = &pair[0];
1405        let next = &pair[1];
1406        if previous.frame_index >= next.frame_index {
1407            return invalid("IndexShard", "FrameEntry rows are not sorted and unique");
1408        }
1409        let previous_end = previous
1410            .tar_stream_offset
1411            .checked_add(previous.decompressed_size as u64)
1412            .ok_or(FormatError::MetadataArithmeticOverflow {
1413                structure: "FrameEntry",
1414            })?;
1415        if next.frame_index == previous.frame_index + 1 {
1416            if next.tar_stream_offset != previous_end {
1417                return invalid(
1418                    "FrameEntry",
1419                    "consecutive tar stream offsets are not packed",
1420                );
1421            }
1422        } else if next.tar_stream_offset <= previous_end {
1423            return invalid("FrameEntry", "non-consecutive tar stream offsets overlap");
1424        }
1425    }
1426    Ok(())
1427}
1428
1429fn validate_envelope_table(
1430    envelopes: &[EnvelopeEntry],
1431    limits: MetadataLimits,
1432) -> Result<(), FormatError> {
1433    for envelope in envelopes {
1434        if envelope.frame_count == 0 || envelope.plaintext_size == 0 {
1435            return invalid("EnvelopeEntry", "payload envelope has no frame plaintext");
1436        }
1437        validate_encrypted_extent(
1438            "EnvelopeEntry",
1439            envelope.data_block_count,
1440            envelope.encrypted_size,
1441            limits.block_size,
1442        )?;
1443        validate_fec_class_extent(
1444            "EnvelopeEntry",
1445            envelope.data_block_count,
1446            envelope.parity_block_count,
1447            limits.max_payload_data_shards,
1448            limits.max_payload_parity_shards,
1449        )?;
1450    }
1451    for pair in envelopes.windows(2) {
1452        if pair[0].envelope_index >= pair[1].envelope_index {
1453            return invalid("IndexShard", "EnvelopeEntry rows are not sorted and unique");
1454        }
1455    }
1456    Ok(())
1457}
1458
1459fn validate_file_order(
1460    files: &[FileEntry],
1461    paths: &[Vec<u8>],
1462    starts: &[u64],
1463) -> Result<(), FormatError> {
1464    for idx in 1..files.len() {
1465        let previous_key = (
1466            &files[idx - 1].path_hash,
1467            paths[idx - 1].as_slice(),
1468            starts[idx - 1],
1469        );
1470        let current_key = (&files[idx].path_hash, paths[idx].as_slice(), starts[idx]);
1471        if previous_key >= current_key {
1472            return invalid("IndexShard", "FileEntry rows are not sorted and unique");
1473        }
1474    }
1475    Ok(())
1476}
1477
1478fn validate_file_frame_range(
1479    file: &FileEntry,
1480    frames: &[FrameEntry],
1481    frame_by_index: &HashMap<u64, usize>,
1482) -> Result<(), FormatError> {
1483    let first = frame_for_file(file, frame_by_index, frames, file.first_frame_index)?;
1484    if file.offset_in_first_frame_plaintext >= first.decompressed_size {
1485        return invalid(
1486            "FileEntry",
1487            "offset in first frame is outside the first referenced frame",
1488        );
1489    }
1490
1491    let mut bytes_before_last =
1492        first.decompressed_size as u64 - file.offset_in_first_frame_plaintext as u64;
1493    if file.frame_count == 1 {
1494        if file.tar_member_group_size > bytes_before_last {
1495            return invalid(
1496                "FileEntry",
1497                "tar member group exceeds the single referenced frame",
1498            );
1499        }
1500        return Ok(());
1501    }
1502
1503    for offset in 1..(file.frame_count as u64 - 1) {
1504        let frame_index = file.first_frame_index.checked_add(offset).ok_or(
1505            FormatError::MetadataArithmeticOverflow {
1506                structure: "FileEntry",
1507            },
1508        )?;
1509        let frame = frame_for_file(file, frame_by_index, frames, frame_index)?;
1510        bytes_before_last = bytes_before_last
1511            .checked_add(frame.decompressed_size as u64)
1512            .ok_or(FormatError::MetadataArithmeticOverflow {
1513                structure: "FileEntry",
1514            })?;
1515    }
1516
1517    let last_index = file
1518        .first_frame_index
1519        .checked_add(file.frame_count as u64 - 1)
1520        .ok_or(FormatError::MetadataArithmeticOverflow {
1521            structure: "FileEntry",
1522        })?;
1523    let last = frame_for_file(file, frame_by_index, frames, last_index)?;
1524    let max_size = bytes_before_last
1525        .checked_add(last.decompressed_size as u64)
1526        .ok_or(FormatError::MetadataArithmeticOverflow {
1527            structure: "FileEntry",
1528        })?;
1529    if file.tar_member_group_size <= bytes_before_last || file.tar_member_group_size > max_size {
1530        return invalid("FileEntry", "frame range is not minimal");
1531    }
1532    Ok(())
1533}
1534
1535fn validate_frame_envelope_binding(
1536    frame: &FrameEntry,
1537    envelope: &EnvelopeEntry,
1538) -> Result<(), FormatError> {
1539    let envelope_frame_end = envelope
1540        .first_frame_index
1541        .checked_add(envelope.frame_count as u64)
1542        .ok_or(FormatError::MetadataArithmeticOverflow {
1543            structure: "EnvelopeEntry",
1544        })?;
1545    if frame.frame_index < envelope.first_frame_index || frame.frame_index >= envelope_frame_end {
1546        return invalid("FrameEntry", "frame index is outside envelope frame range");
1547    }
1548    let end = frame
1549        .offset_in_envelope
1550        .checked_add(frame.compressed_size)
1551        .ok_or(FormatError::MetadataArithmeticOverflow {
1552            structure: "FrameEntry",
1553        })?;
1554    if end > envelope.plaintext_size {
1555        return invalid("FrameEntry", "frame slice exceeds envelope plaintext");
1556    }
1557    Ok(())
1558}
1559
1560fn validate_frame_slices_by_envelope(
1561    frames: &[FrameEntry],
1562    envelopes: &[EnvelopeEntry],
1563) -> Result<(), FormatError> {
1564    for envelope in envelopes {
1565        let mut slices = frames
1566            .iter()
1567            .filter(|frame| frame.envelope_index == envelope.envelope_index)
1568            .map(|frame| {
1569                let end = frame
1570                    .offset_in_envelope
1571                    .checked_add(frame.compressed_size)
1572                    .ok_or(FormatError::MetadataArithmeticOverflow {
1573                        structure: "FrameEntry",
1574                    })?;
1575                Ok((frame.offset_in_envelope, end, frame.frame_index))
1576            })
1577            .collect::<Result<Vec<_>, FormatError>>()?;
1578        slices.sort_unstable_by_key(|slice| (slice.0, slice.2));
1579        for pair in slices.windows(2) {
1580            if pair[0].1 > pair[1].0 {
1581                return invalid("FrameEntry", "frame slices overlap inside an envelope");
1582            }
1583        }
1584
1585        let contains_complete_global_range = (0..envelope.frame_count as u64).all(|offset| {
1586            envelope
1587                .first_frame_index
1588                .checked_add(offset)
1589                .map(|index| slices.iter().any(|slice| slice.2 == index))
1590                .unwrap_or(false)
1591        });
1592        if contains_complete_global_range {
1593            let mut cursor = 0u32;
1594            for (start, end, _) in slices {
1595                if start != cursor {
1596                    return invalid("EnvelopeEntry", "complete local envelope has frame gap");
1597                }
1598                cursor = end;
1599            }
1600            if cursor != envelope.plaintext_size {
1601                return invalid(
1602                    "EnvelopeEntry",
1603                    "complete local envelope does not cover plaintext",
1604                );
1605            }
1606        }
1607    }
1608    Ok(())
1609}
1610
1611fn validate_directory_hint_entries(
1612    entries: &[DirectoryHintEntry],
1613    bytes: &[u8],
1614    header: &DirectoryHintTableHeader,
1615    locating_shard: &DirectoryHintShardEntry,
1616    index_root_shard_count: u32,
1617) -> Result<usize, FormatError> {
1618    let structure = "DirectoryHintTable";
1619    if index_root_shard_count == 0 {
1620        return invalid(structure, "directory hints require IndexRoot shard rows");
1621    }
1622    if entries.is_empty() {
1623        return invalid(structure, "located directory hint table is empty");
1624    }
1625    if entries[0].dir_hash != locating_shard.first_dir_hash {
1626        return invalid(
1627            structure,
1628            "first DirectoryHintEntry hash does not match locating row",
1629        );
1630    }
1631    if entries[entries.len() - 1].dir_hash != locating_shard.last_dir_hash {
1632        return invalid(
1633            structure,
1634            "last DirectoryHintEntry hash does not match locating row",
1635        );
1636    }
1637
1638    let mut max_shard_list_end = 0usize;
1639    for entry in entries {
1640        if entry.shard_count == 0 {
1641            return invalid("DirectoryHintEntry", "shard count is zero");
1642        }
1643        let start = entry.shard_list_start_index as usize;
1644        let end = start.checked_add(entry.shard_count as usize).ok_or(
1645            FormatError::MetadataArithmeticOverflow {
1646                structure: "DirectoryHintEntry",
1647            },
1648        )?;
1649        max_shard_list_end = max_shard_list_end.max(end);
1650    }
1651    let byte_len = checked_mul(max_shard_list_end, 4, structure)?;
1652    let shard_list_offset = to_usize(header.shard_list_offset, structure)?;
1653    let shard_list_end = checked_add(shard_list_offset, byte_len, structure)?;
1654    if shard_list_end > bytes.len() {
1655        return invalid(structure, "shard list exceeds plaintext");
1656    }
1657    Ok(max_shard_list_end)
1658}
1659
1660fn validate_directory_hint_paths_and_lists(
1661    entries: &[DirectoryHintEntry],
1662    shard_row_indexes: &[u32],
1663    string_pool: &[u8],
1664    locating_shard: &DirectoryHintShardEntry,
1665    index_root_shard_count: u32,
1666    max_path_length: u32,
1667) -> Result<Vec<Vec<u8>>, FormatError> {
1668    let mut paths = Vec::with_capacity(entries.len());
1669    let mut seen_paths = HashSet::new();
1670    for entry in entries {
1671        let path = if entry.path_length == 0 {
1672            if entry.path_offset != 0 || entry.dir_hash != hash_prefix(b"") {
1673                return invalid(
1674                    "DirectoryHintEntry",
1675                    "root directory entry is not canonical",
1676                );
1677            }
1678            &[][..]
1679        } else {
1680            let path = string_slice(
1681                string_pool,
1682                entry.path_offset,
1683                entry.path_length as u64,
1684                "DirectoryHintEntry",
1685            )?;
1686            validate_directory_path_bytes(path, max_path_length)?;
1687            path
1688        };
1689        if hash_prefix(path) != entry.dir_hash {
1690            return invalid(
1691                "DirectoryHintEntry",
1692                "dir_hash does not match string-pool path",
1693            );
1694        }
1695        if !seen_paths.insert(path.to_vec()) {
1696            return invalid("DirectoryHintEntry", "duplicate directory path");
1697        }
1698
1699        let start = entry.shard_list_start_index as usize;
1700        let end = start.checked_add(entry.shard_count as usize).ok_or(
1701            FormatError::MetadataArithmeticOverflow {
1702                structure: "DirectoryHintEntry",
1703            },
1704        )?;
1705        let rows = shard_row_indexes
1706            .get(start..end)
1707            .ok_or(FormatError::InvalidMetadata {
1708                structure: "DirectoryHintEntry",
1709                reason: "shard-row-index range is out of bounds",
1710            })?;
1711        for pair in rows.windows(2) {
1712            if pair[0] >= pair[1] {
1713                return invalid(
1714                    "DirectoryHintEntry",
1715                    "shard-row-index list is not sorted and unique",
1716                );
1717            }
1718        }
1719        if rows.iter().any(|row| *row >= index_root_shard_count) {
1720            return invalid(
1721                "DirectoryHintEntry",
1722                "shard-row-index is outside IndexRoot shard table",
1723            );
1724        }
1725        paths.push(path.to_vec());
1726    }
1727
1728    for idx in 1..entries.len() {
1729        let previous_key = (&entries[idx - 1].dir_hash, paths[idx - 1].as_slice());
1730        let current_key = (&entries[idx].dir_hash, paths[idx].as_slice());
1731        if previous_key >= current_key {
1732            return invalid(
1733                "DirectoryHintTable",
1734                "DirectoryHintEntry rows are not sorted and unique",
1735            );
1736        }
1737    }
1738    if entries[0].dir_hash != locating_shard.first_dir_hash
1739        || entries[entries.len() - 1].dir_hash != locating_shard.last_dir_hash
1740    {
1741        return invalid(
1742            "DirectoryHintTable",
1743            "entry hash bounds do not match locating shard",
1744        );
1745    }
1746
1747    Ok(paths)
1748}
1749
1750fn candidate_interval_indexes<T>(
1751    entries: &[T],
1752    target_hash: [u8; 8],
1753    scan_cap_per_direction: usize,
1754    first_hash: impl Fn(&T) -> [u8; 8],
1755    last_hash: impl Fn(&T) -> [u8; 8],
1756) -> Result<Vec<usize>, FormatError> {
1757    if entries.is_empty() {
1758        return Ok(Vec::new());
1759    }
1760    let upper = entries.partition_point(|entry| first_hash(entry) <= target_hash);
1761    if upper == 0 {
1762        return Ok(Vec::new());
1763    }
1764    let landing = upper - 1;
1765    if last_hash(&entries[landing]) < target_hash {
1766        return Ok(Vec::new());
1767    }
1768
1769    let mut start = landing;
1770    let mut left_scanned = 0usize;
1771    while start > 0
1772        && first_hash(&entries[start - 1]) <= target_hash
1773        && last_hash(&entries[start - 1]) >= target_hash
1774    {
1775        left_scanned += 1;
1776        if left_scanned > scan_cap_per_direction {
1777            return Err(FormatError::HashPrefixCollisionRunExceeded);
1778        }
1779        start -= 1;
1780    }
1781
1782    let mut end = landing + 1;
1783    let mut right_scanned = 0usize;
1784    while end < entries.len()
1785        && first_hash(&entries[end]) <= target_hash
1786        && last_hash(&entries[end]) >= target_hash
1787    {
1788        right_scanned += 1;
1789        if right_scanned > scan_cap_per_direction {
1790            return Err(FormatError::HashPrefixCollisionRunExceeded);
1791        }
1792        end += 1;
1793    }
1794
1795    Ok((start..end).collect())
1796}
1797
1798pub fn validate_file_path_bytes(path: &[u8], max_path_length: u32) -> Result<(), FormatError> {
1799    if path.is_empty() || path.len() > max_path_length as usize {
1800        return Err(FormatError::UnsafeArchivePath);
1801    }
1802    validate_relative_path(path, false)
1803}
1804
1805pub fn validate_directory_path_bytes(path: &[u8], max_path_length: u32) -> Result<(), FormatError> {
1806    if path.len() > max_path_length as usize {
1807        return Err(FormatError::UnsafeArchivePath);
1808    }
1809    validate_relative_path(path, true)
1810}
1811
1812fn validate_relative_path(path: &[u8], allow_empty_root: bool) -> Result<(), FormatError> {
1813    if path.is_empty() {
1814        return if allow_empty_root {
1815            Ok(())
1816        } else {
1817            Err(FormatError::UnsafeArchivePath)
1818        };
1819    }
1820    if path.contains(&0) || path.contains(&b'\\') || path.contains(&b':') || path[0] == b'/' {
1821        return Err(FormatError::UnsafeArchivePath);
1822    }
1823    let path_str = std::str::from_utf8(path).map_err(|_| FormatError::UnsafeArchivePath)?;
1824    if !path_str.nfc().eq(path_str.chars()) {
1825        return Err(FormatError::UnsafeArchivePath);
1826    }
1827    for component in path_str.split('/') {
1828        if component.is_empty() || component == "." || component == ".." {
1829            return Err(FormatError::UnsafeArchivePath);
1830        }
1831        if is_windows_device_component(component) {
1832            return Err(FormatError::UnsafeArchivePath);
1833        }
1834    }
1835    Ok(())
1836}
1837
1838fn is_windows_device_component(component: &str) -> bool {
1839    let stem = component
1840        .split('.')
1841        .next()
1842        .unwrap_or(component)
1843        .trim_end_matches([' ', '.']);
1844    let upper = stem.to_ascii_uppercase();
1845    matches!(
1846        upper.as_str(),
1847        "CON"
1848            | "PRN"
1849            | "AUX"
1850            | "NUL"
1851            | "CLOCK$"
1852            | "COM1"
1853            | "COM2"
1854            | "COM3"
1855            | "COM4"
1856            | "COM5"
1857            | "COM6"
1858            | "COM7"
1859            | "COM8"
1860            | "COM9"
1861            | "COM\u{00b9}"
1862            | "COM\u{00b2}"
1863            | "COM\u{00b3}"
1864            | "LPT1"
1865            | "LPT2"
1866            | "LPT3"
1867            | "LPT4"
1868            | "LPT5"
1869            | "LPT6"
1870            | "LPT7"
1871            | "LPT8"
1872            | "LPT9"
1873            | "LPT\u{00b9}"
1874            | "LPT\u{00b2}"
1875            | "LPT\u{00b3}"
1876    )
1877}
1878
1879fn validate_encrypted_extent(
1880    structure: &'static str,
1881    data_block_count: u32,
1882    encrypted_size: u32,
1883    block_size: u32,
1884) -> Result<(), FormatError> {
1885    if data_block_count == 0 || encrypted_size == 0 {
1886        return invalid(structure, "encrypted object has zero data blocks or size");
1887    }
1888    let expected = (data_block_count as u64)
1889        .checked_mul(block_size as u64)
1890        .ok_or(FormatError::MetadataArithmeticOverflow { structure })?;
1891    if expected > u32::MAX as u64 || expected != encrypted_size as u64 {
1892        return invalid(
1893            structure,
1894            "encrypted_size is not data_block_count * block_size",
1895        );
1896    }
1897    Ok(())
1898}
1899
1900fn validate_fec_class_extent(
1901    structure: &'static str,
1902    data_block_count: u32,
1903    parity_block_count: u32,
1904    data_shard_max: u16,
1905    parity_shard_max: u16,
1906) -> Result<(), FormatError> {
1907    if data_block_count > data_shard_max as u32 {
1908        return invalid(structure, "data_block_count exceeds class maximum");
1909    }
1910    if parity_block_count > parity_shard_max as u32 {
1911        return invalid(structure, "parity_block_count exceeds class maximum");
1912    }
1913    let total = data_block_count as u64 + parity_block_count as u64;
1914    if total > REED_SOLOMON_GF16_MAX_TOTAL_SHARDS {
1915        return invalid(
1916            structure,
1917            "data_block_count + parity_block_count exceeds ReedSolomonGF16 limit",
1918        );
1919    }
1920    Ok(())
1921}
1922
1923fn frame_for_file<'a>(
1924    _file: &FileEntry,
1925    frame_by_index: &HashMap<u64, usize>,
1926    frames: &'a [FrameEntry],
1927    frame_index: u64,
1928) -> Result<&'a FrameEntry, FormatError> {
1929    frame_by_index
1930        .get(&frame_index)
1931        .and_then(|idx| frames.get(*idx))
1932        .ok_or(FormatError::InvalidMetadata {
1933            structure: "FileEntry",
1934            reason: "referenced FrameEntry is missing",
1935        })
1936}
1937
1938struct CountedTableSpec<T> {
1939    structure: &'static str,
1940    name: &'static str,
1941    count: u64,
1942    offset: u64,
1943    entry_len: usize,
1944    parse: fn(&[u8]) -> Result<T, FormatError>,
1945}
1946
1947fn parse_counted_table<T>(
1948    bytes: &[u8],
1949    spec: CountedTableSpec<T>,
1950    cursor: &mut usize,
1951) -> Result<Vec<T>, FormatError> {
1952    if spec.count == 0 {
1953        if spec.offset != 0 {
1954            return invalid(spec.structure, "absent counted table has non-zero offset");
1955        }
1956        return Ok(Vec::new());
1957    }
1958    expect_offset(spec.structure, spec.name, spec.offset, *cursor)?;
1959    let count = to_usize(spec.count, spec.structure)?;
1960    let bytes_len = checked_mul(count, spec.entry_len, spec.structure)?;
1961    let table = slice(bytes, *cursor, bytes_len, spec.structure)?;
1962    *cursor = checked_add(*cursor, bytes_len, spec.structure)?;
1963    table.chunks_exact(spec.entry_len).map(spec.parse).collect()
1964}
1965
1966fn parse_u32_array(bytes: &[u8], structure: &'static str) -> Result<Vec<u32>, FormatError> {
1967    let mut out = Vec::with_capacity(bytes.len() / 4);
1968    for chunk in bytes.chunks_exact(4) {
1969        out.push(read_u32(chunk, 0, structure)?);
1970    }
1971    Ok(out)
1972}
1973
1974fn string_slice<'a>(
1975    string_pool: &'a [u8],
1976    offset: u64,
1977    length: u64,
1978    structure: &'static str,
1979) -> Result<&'a [u8], FormatError> {
1980    let start = to_usize(offset, structure)?;
1981    let len = to_usize(length, structure)?;
1982    slice(string_pool, start, len, structure)
1983}
1984
1985fn shard_entry_sort_key(entry: &ShardEntry) -> ([u8; 8], [u8; 8], u64) {
1986    (
1987        entry.first_path_hash,
1988        entry.last_path_hash,
1989        entry.shard_index,
1990    )
1991}
1992
1993fn directory_hint_shard_sort_key(entry: &DirectoryHintShardEntry) -> ([u8; 8], [u8; 8], u64) {
1994    (
1995        entry.first_dir_hash,
1996        entry.last_dir_hash,
1997        entry.hint_shard_index,
1998    )
1999}
2000
2001fn table_offset(len: usize, cursor: usize) -> u32 {
2002    if len == 0 {
2003        0
2004    } else {
2005        cursor as u32
2006    }
2007}
2008
2009fn expect_magic(
2010    structure: &'static str,
2011    expected: [u8; 4],
2012    actual: [u8; 4],
2013) -> Result<(), FormatError> {
2014    if actual != expected {
2015        return Err(FormatError::BadMagic { structure });
2016    }
2017    Ok(())
2018}
2019
2020fn expect_zero(structure: &'static str, bytes: &[u8]) -> Result<(), FormatError> {
2021    if bytes.iter().any(|byte| *byte != 0) {
2022        return Err(FormatError::NonZeroReserved { structure });
2023    }
2024    Ok(())
2025}
2026
2027fn expect_offset(
2028    structure: &'static str,
2029    name: &'static str,
2030    actual: u64,
2031    expected: usize,
2032) -> Result<(), FormatError> {
2033    if actual != expected as u64 {
2034        return Err(FormatError::InvalidMetadata {
2035            structure,
2036            reason: name,
2037        });
2038    }
2039    Ok(())
2040}
2041
2042fn slice<'a>(
2043    bytes: &'a [u8],
2044    offset: usize,
2045    len: usize,
2046    structure: &'static str,
2047) -> Result<&'a [u8], FormatError> {
2048    let end = checked_add(offset, len, structure)?;
2049    bytes.get(offset..end).ok_or(FormatError::InvalidMetadata {
2050        structure,
2051        reason: "range is out of bounds",
2052    })
2053}
2054
2055fn read_array<const N: usize>(
2056    bytes: &[u8],
2057    offset: usize,
2058    structure: &'static str,
2059) -> Result<[u8; N], FormatError> {
2060    let mut out = [0u8; N];
2061    out.copy_from_slice(slice(bytes, offset, N, structure)?);
2062    Ok(out)
2063}
2064
2065fn read_u32(bytes: &[u8], offset: usize, structure: &'static str) -> Result<u32, FormatError> {
2066    let raw = read_array::<4>(bytes, offset, structure)?;
2067    Ok(u32::from_le_bytes(raw))
2068}
2069
2070fn read_u64(bytes: &[u8], offset: usize, structure: &'static str) -> Result<u64, FormatError> {
2071    let raw = read_array::<8>(bytes, offset, structure)?;
2072    Ok(u64::from_le_bytes(raw))
2073}
2074
2075fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
2076    bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
2077}
2078
2079fn write_u64(bytes: &mut [u8], offset: usize, value: u64) {
2080    bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
2081}
2082
2083fn checked_add(lhs: usize, rhs: usize, structure: &'static str) -> Result<usize, FormatError> {
2084    lhs.checked_add(rhs)
2085        .ok_or(FormatError::MetadataArithmeticOverflow { structure })
2086}
2087
2088fn checked_mul(lhs: usize, rhs: usize, structure: &'static str) -> Result<usize, FormatError> {
2089    lhs.checked_mul(rhs)
2090        .ok_or(FormatError::MetadataArithmeticOverflow { structure })
2091}
2092
2093fn to_usize(value: u64, structure: &'static str) -> Result<usize, FormatError> {
2094    usize::try_from(value).map_err(|_| FormatError::MetadataArithmeticOverflow { structure })
2095}
2096
2097fn invalid<T>(structure: &'static str, reason: &'static str) -> Result<T, FormatError> {
2098    Err(FormatError::InvalidMetadata { structure, reason })
2099}
2100
2101#[cfg(test)]
2102mod tests {
2103    use super::*;
2104
2105    #[test]
2106    fn default_reader_caps_match_v41() {
2107        let limits = MetadataLimits::default();
2108        assert_eq!(limits.max_shard_count, 1_000_000);
2109        assert_eq!(limits.max_directory_hint_shards, 1_000_000);
2110        assert_eq!(limits.max_files_per_index_shard, 1_000_000);
2111        assert_eq!(limits.max_entries_per_directory_hint_shard, 1_000_000);
2112        assert_eq!(limits.max_hash_collision_shard_scan, 16);
2113    }
2114
2115    #[test]
2116    fn index_root_rejects_shard_extent_above_crypto_header_class_limits() {
2117        let path_hash = hash_prefix(b"a.txt");
2118        let root = IndexRoot {
2119            header: IndexRootHeader {
2120                file_count: 1,
2121                ..IndexRootHeader::empty()
2122            },
2123            shards: vec![ShardEntry {
2124                shard_index: 0,
2125                first_block_index: 1,
2126                data_block_count: 1,
2127                parity_block_count: 2,
2128                encrypted_size: 4096,
2129                decompressed_size: 64,
2130                file_count: 1,
2131                first_path_hash: path_hash,
2132                last_path_hash: path_hash,
2133            }],
2134            directory_hint_shards: Vec::new(),
2135        };
2136        let limits = MetadataLimits {
2137            max_index_parity_shards: 1,
2138            ..MetadataLimits::default()
2139        };
2140
2141        assert_eq!(
2142            IndexRoot::parse(&root.to_bytes(), false, limits).unwrap_err(),
2143            FormatError::InvalidMetadata {
2144                structure: "ShardEntry",
2145                reason: "parity_block_count exceeds class maximum",
2146            }
2147        );
2148    }
2149
2150    #[test]
2151    fn metadata_fec_extent_rejects_reed_solomon_total_overflow() {
2152        assert_eq!(
2153            validate_fec_class_extent("EnvelopeEntry", 65_535, 1, u16::MAX, u16::MAX).unwrap_err(),
2154            FormatError::InvalidMetadata {
2155                structure: "EnvelopeEntry",
2156                reason: "data_block_count + parity_block_count exceeds ReedSolomonGF16 limit",
2157            }
2158        );
2159    }
2160
2161    #[test]
2162    fn parses_valid_empty_index_root() {
2163        let root = IndexRoot {
2164            header: IndexRootHeader::empty(),
2165            shards: Vec::new(),
2166            directory_hint_shards: Vec::new(),
2167        };
2168
2169        let bytes = root.to_bytes();
2170        let parsed = IndexRoot::parse(&bytes, false, MetadataLimits::default()).unwrap();
2171
2172        assert_eq!(parsed.header.file_count, 0);
2173        assert!(parsed.shards.is_empty());
2174        assert!(parsed.directory_hint_shards.is_empty());
2175    }
2176
2177    #[test]
2178    fn index_root_rejects_nonzero_offsets_for_absent_counted_tables() {
2179        let mut root = IndexRoot {
2180            header: IndexRootHeader::empty(),
2181            shards: Vec::new(),
2182            directory_hint_shards: Vec::new(),
2183        };
2184
2185        let mut bytes = root.to_bytes();
2186        write_u64(&mut bytes, 88, INDEX_ROOT_LEN as u64);
2187        assert_eq!(
2188            IndexRoot::parse(&bytes, false, MetadataLimits::default()).unwrap_err(),
2189            FormatError::InvalidMetadata {
2190                structure: "IndexRoot",
2191                reason: "absent shard table has non-zero offset",
2192            }
2193        );
2194
2195        root.header.file_count = 1;
2196        root.shards.push(ShardEntry {
2197            shard_index: 0,
2198            first_block_index: 1,
2199            data_block_count: 1,
2200            parity_block_count: 0,
2201            encrypted_size: 4096,
2202            decompressed_size: 128,
2203            file_count: 1,
2204            first_path_hash: hash_prefix(b"a.txt"),
2205            last_path_hash: hash_prefix(b"a.txt"),
2206        });
2207        let mut bytes = root.to_bytes();
2208        write_u64(&mut bytes, 96, (INDEX_ROOT_LEN + SHARD_ENTRY_LEN) as u64);
2209        assert_eq!(
2210            IndexRoot::parse(&bytes, false, MetadataLimits::default()).unwrap_err(),
2211            FormatError::InvalidMetadata {
2212                structure: "IndexRoot",
2213                reason: "absent directory hint shard table has non-zero offset",
2214            }
2215        );
2216    }
2217
2218    #[test]
2219    fn index_root_rejects_has_dictionary_with_zero_dictionary_fields() {
2220        let root = IndexRoot {
2221            header: IndexRootHeader::empty(),
2222            shards: Vec::new(),
2223            directory_hint_shards: Vec::new(),
2224        };
2225
2226        assert_eq!(
2227            IndexRoot::parse(&root.to_bytes(), true, MetadataLimits::default()).unwrap_err(),
2228            FormatError::InvalidMetadata {
2229                structure: "IndexRoot",
2230                reason: "dictionary data block count is zero while has_dictionary is true",
2231            }
2232        );
2233    }
2234
2235    #[test]
2236    fn index_root_rejects_empty_archive_with_dictionary_extent() {
2237        let root = IndexRoot {
2238            header: IndexRootHeader {
2239                dictionary_first_block: 1,
2240                dictionary_data_block_count: 1,
2241                dictionary_encrypted_size: 4096,
2242                dictionary_decompressed_size: 16,
2243                ..IndexRootHeader::empty()
2244            },
2245            shards: Vec::new(),
2246            directory_hint_shards: Vec::new(),
2247        };
2248
2249        assert_eq!(
2250            IndexRoot::parse(&root.to_bytes(), true, MetadataLimits::default()).unwrap_err(),
2251            FormatError::InvalidMetadata {
2252                structure: "IndexRoot",
2253                reason: "empty archive cannot use dictionary",
2254            }
2255        );
2256    }
2257
2258    #[test]
2259    fn encrypted_object_extents_reject_zero_data_or_size_for_all_metadata_rows() {
2260        assert_eq!(
2261            validate_encrypted_extent("ManifestFooter.IndexRoot", 0, 4096, 4096).unwrap_err(),
2262            FormatError::InvalidMetadata {
2263                structure: "ManifestFooter.IndexRoot",
2264                reason: "encrypted object has zero data blocks or size",
2265            }
2266        );
2267        assert_eq!(
2268            validate_encrypted_extent("EnvelopeEntry", 1, 0, 4096).unwrap_err(),
2269            FormatError::InvalidMetadata {
2270                structure: "EnvelopeEntry",
2271                reason: "encrypted object has zero data blocks or size",
2272            }
2273        );
2274
2275        let path_hash = hash_prefix(b"a.txt");
2276        let mut root = IndexRoot {
2277            header: IndexRootHeader {
2278                file_count: 1,
2279                ..IndexRootHeader::empty()
2280            },
2281            shards: vec![ShardEntry {
2282                shard_index: 0,
2283                first_block_index: 1,
2284                data_block_count: 0,
2285                parity_block_count: 0,
2286                encrypted_size: 4096,
2287                decompressed_size: 128,
2288                file_count: 1,
2289                first_path_hash: path_hash,
2290                last_path_hash: path_hash,
2291            }],
2292            directory_hint_shards: Vec::new(),
2293        };
2294        assert_eq!(
2295            IndexRoot::parse(&root.to_bytes(), false, MetadataLimits::default()).unwrap_err(),
2296            FormatError::InvalidMetadata {
2297                structure: "ShardEntry",
2298                reason: "encrypted object has zero data blocks or size",
2299            }
2300        );
2301
2302        root.shards[0].data_block_count = 1;
2303        root.shards[0].encrypted_size = 4096;
2304        root.directory_hint_shards.push(DirectoryHintShardEntry {
2305            hint_shard_index: 0,
2306            first_dir_hash: hash_prefix(b""),
2307            last_dir_hash: hash_prefix(b""),
2308            first_block_index: 2,
2309            data_block_count: 1,
2310            parity_block_count: 0,
2311            encrypted_size: 0,
2312            decompressed_size: 72,
2313            entry_count: 1,
2314        });
2315        assert_eq!(
2316            IndexRoot::parse(&root.to_bytes(), false, MetadataLimits::default()).unwrap_err(),
2317            FormatError::InvalidMetadata {
2318                structure: "DirectoryHintShardEntry",
2319                reason: "encrypted object has zero data blocks or size",
2320            }
2321        );
2322
2323        let mut dict_root = IndexRoot {
2324            header: IndexRootHeader {
2325                file_count: 1,
2326                dictionary_first_block: 10,
2327                dictionary_data_block_count: 0,
2328                dictionary_parity_block_count: 0,
2329                dictionary_encrypted_size: 4096,
2330                dictionary_decompressed_size: 32,
2331                ..IndexRootHeader::empty()
2332            },
2333            shards: vec![ShardEntry {
2334                shard_index: 0,
2335                first_block_index: 1,
2336                data_block_count: 1,
2337                parity_block_count: 0,
2338                encrypted_size: 4096,
2339                decompressed_size: 128,
2340                file_count: 1,
2341                first_path_hash: path_hash,
2342                last_path_hash: path_hash,
2343            }],
2344            directory_hint_shards: Vec::new(),
2345        };
2346        assert_eq!(
2347            IndexRoot::parse(&dict_root.to_bytes(), true, MetadataLimits::default()).unwrap_err(),
2348            FormatError::InvalidMetadata {
2349                structure: "IndexRoot",
2350                reason: "dictionary data block count is zero while has_dictionary is true",
2351            }
2352        );
2353        dict_root.header.dictionary_data_block_count = 1;
2354        dict_root.header.dictionary_encrypted_size = 0;
2355        assert_eq!(
2356            IndexRoot::parse(&dict_root.to_bytes(), true, MetadataLimits::default()).unwrap_err(),
2357            FormatError::InvalidMetadata {
2358                structure: "IndexRoot",
2359                reason: "required dictionary field is zero",
2360            }
2361        );
2362    }
2363
2364    #[test]
2365    fn index_root_rejects_dictionary_fields_when_crypto_header_has_no_dictionary() {
2366        let mut root = IndexRoot {
2367            header: IndexRootHeader::empty(),
2368            shards: Vec::new(),
2369            directory_hint_shards: Vec::new(),
2370        };
2371        root.header.dictionary_first_block = 1;
2372        root.header.dictionary_data_block_count = 1;
2373        root.header.dictionary_encrypted_size = 4096;
2374        root.header.dictionary_decompressed_size = 16;
2375
2376        assert_eq!(
2377            IndexRoot::parse(&root.to_bytes(), false, MetadataLimits::default()).unwrap_err(),
2378            FormatError::InvalidMetadata {
2379                structure: "IndexRoot",
2380                reason: "dictionary fields are non-zero while has_dictionary is false",
2381            }
2382        );
2383    }
2384
2385    #[test]
2386    fn rejects_directory_hint_rows_sorted_by_old_v36_key_only() {
2387        let h = [0x10; 8];
2388        let z = [0x20; 8];
2389        let root = IndexRoot {
2390            header: IndexRootHeader {
2391                file_count: 1,
2392                ..IndexRootHeader::empty()
2393            },
2394            shards: vec![ShardEntry {
2395                shard_index: 0,
2396                first_block_index: 0,
2397                data_block_count: 1,
2398                parity_block_count: 1,
2399                encrypted_size: 4096,
2400                decompressed_size: 64,
2401                file_count: 1,
2402                first_path_hash: h,
2403                last_path_hash: z,
2404            }],
2405            directory_hint_shards: vec![
2406                DirectoryHintShardEntry {
2407                    hint_shard_index: 0,
2408                    first_dir_hash: h,
2409                    last_dir_hash: z,
2410                    first_block_index: 10,
2411                    data_block_count: 1,
2412                    parity_block_count: 1,
2413                    encrypted_size: 4096,
2414                    decompressed_size: 72,
2415                    entry_count: 1,
2416                },
2417                DirectoryHintShardEntry {
2418                    hint_shard_index: 1,
2419                    first_dir_hash: h,
2420                    last_dir_hash: h,
2421                    first_block_index: 12,
2422                    data_block_count: 1,
2423                    parity_block_count: 1,
2424                    encrypted_size: 4096,
2425                    decompressed_size: 72,
2426                    entry_count: 1,
2427                },
2428            ],
2429        };
2430
2431        assert_eq!(
2432            IndexRoot::parse(&root.to_bytes(), false, MetadataLimits::default()).unwrap_err(),
2433            FormatError::InvalidMetadata {
2434                structure: "IndexRoot",
2435                reason: "DirectoryHintShardEntry rows are not sorted"
2436            }
2437        );
2438    }
2439
2440    #[test]
2441    fn directory_hint_shard_count_cap_is_independent_from_index_shard_cap() {
2442        let path_hash = hash_prefix(b"a.txt");
2443        let dir_hash = hash_prefix(b"");
2444        let root = IndexRoot {
2445            header: IndexRootHeader {
2446                file_count: 1,
2447                ..IndexRootHeader::empty()
2448            },
2449            shards: vec![ShardEntry {
2450                shard_index: 0,
2451                first_block_index: 1,
2452                data_block_count: 1,
2453                parity_block_count: 0,
2454                encrypted_size: 4096,
2455                decompressed_size: 128,
2456                file_count: 1,
2457                first_path_hash: path_hash,
2458                last_path_hash: path_hash,
2459            }],
2460            directory_hint_shards: vec![DirectoryHintShardEntry {
2461                hint_shard_index: 0,
2462                first_dir_hash: dir_hash,
2463                last_dir_hash: dir_hash,
2464                first_block_index: 2,
2465                data_block_count: 1,
2466                parity_block_count: 0,
2467                encrypted_size: 4096,
2468                decompressed_size: 72,
2469                entry_count: 1,
2470            }],
2471        };
2472        let limits = MetadataLimits {
2473            max_shard_count: 1,
2474            max_directory_hint_shards: 1,
2475            ..MetadataLimits::default()
2476        };
2477        IndexRoot::parse(&root.to_bytes(), false, limits).unwrap();
2478
2479        let rejecting_limits = MetadataLimits {
2480            max_directory_hint_shards: 0,
2481            ..limits
2482        };
2483        assert_eq!(
2484            IndexRoot::parse(&root.to_bytes(), false, rejecting_limits).unwrap_err(),
2485            FormatError::InvalidMetadata {
2486                structure: "IndexRoot",
2487                reason: "directory hint shard count exceeds resource cap",
2488            }
2489        );
2490    }
2491
2492    #[test]
2493    fn directory_hint_paths_obey_configured_max_path_length() {
2494        let path = b"toolong".to_vec();
2495        let table = DirectoryHintTable {
2496            header: DirectoryHintTableHeader {
2497                version: 1,
2498                hint_shard_index: 0,
2499                entry_count: 0,
2500                entry_table_offset: 0,
2501                shard_list_offset: 0,
2502                string_pool_offset: 0,
2503                string_pool_size: 0,
2504            },
2505            entries: vec![DirectoryHintEntry {
2506                dir_hash: hash_prefix(&path),
2507                path_offset: 0,
2508                path_length: path.len() as u32,
2509                shard_list_start_index: 0,
2510                shard_count: 1,
2511            }],
2512            shard_row_indexes: vec![0],
2513            string_pool: path.clone(),
2514            entry_paths: vec![path.clone()],
2515        };
2516        let bytes = table.to_bytes();
2517        let locating = DirectoryHintShardEntry {
2518            hint_shard_index: 0,
2519            first_dir_hash: hash_prefix(&path),
2520            last_dir_hash: hash_prefix(&path),
2521            first_block_index: 0,
2522            data_block_count: 1,
2523            parity_block_count: 0,
2524            encrypted_size: 4096,
2525            decompressed_size: bytes.len() as u32,
2526            entry_count: 1,
2527        };
2528        let limits = MetadataLimits {
2529            max_path_length: 3,
2530            ..MetadataLimits::default()
2531        };
2532
2533        assert_eq!(
2534            DirectoryHintTable::parse(&bytes, &locating, 1, limits).unwrap_err(),
2535            FormatError::UnsafeArchivePath
2536        );
2537    }
2538
2539    #[test]
2540    fn directory_hint_table_rejects_wrong_hint_shard_identity() {
2541        let path = b"dir".to_vec();
2542        let table = DirectoryHintTable {
2543            header: DirectoryHintTableHeader {
2544                version: 1,
2545                hint_shard_index: 5,
2546                entry_count: 0,
2547                entry_table_offset: 0,
2548                shard_list_offset: 0,
2549                string_pool_offset: 0,
2550                string_pool_size: 0,
2551            },
2552            entries: vec![DirectoryHintEntry {
2553                dir_hash: hash_prefix(&path),
2554                path_offset: 0,
2555                path_length: path.len() as u32,
2556                shard_list_start_index: 0,
2557                shard_count: 1,
2558            }],
2559            shard_row_indexes: vec![0],
2560            string_pool: path.clone(),
2561            entry_paths: vec![path.clone()],
2562        };
2563        let bytes = table.to_bytes();
2564        let locating = DirectoryHintShardEntry {
2565            hint_shard_index: 6,
2566            first_dir_hash: hash_prefix(&path),
2567            last_dir_hash: hash_prefix(&path),
2568            first_block_index: 0,
2569            data_block_count: 1,
2570            parity_block_count: 0,
2571            encrypted_size: 4096,
2572            decompressed_size: bytes.len() as u32,
2573            entry_count: 1,
2574        };
2575
2576        assert_eq!(
2577            DirectoryHintTable::parse(&bytes, &locating, 1, MetadataLimits::default()).unwrap_err(),
2578            FormatError::InvalidMetadata {
2579                structure: "DirectoryHintTable",
2580                reason: "hint shard index does not match locating DirectoryHintShardEntry",
2581            }
2582        );
2583    }
2584
2585    #[test]
2586    fn directory_hint_table_rejects_empty_shard_lists() {
2587        let path = b"dir".to_vec();
2588        let table = DirectoryHintTable {
2589            header: DirectoryHintTableHeader {
2590                version: 1,
2591                hint_shard_index: 0,
2592                entry_count: 0,
2593                entry_table_offset: 0,
2594                shard_list_offset: 0,
2595                string_pool_offset: 0,
2596                string_pool_size: 0,
2597            },
2598            entries: vec![DirectoryHintEntry {
2599                dir_hash: hash_prefix(&path),
2600                path_offset: 0,
2601                path_length: path.len() as u32,
2602                shard_list_start_index: 0,
2603                shard_count: 0,
2604            }],
2605            shard_row_indexes: Vec::new(),
2606            string_pool: path.clone(),
2607            entry_paths: vec![path.clone()],
2608        };
2609        let bytes = table.to_bytes();
2610        let locating = DirectoryHintShardEntry {
2611            hint_shard_index: 0,
2612            first_dir_hash: hash_prefix(&path),
2613            last_dir_hash: hash_prefix(&path),
2614            first_block_index: 0,
2615            data_block_count: 1,
2616            parity_block_count: 0,
2617            encrypted_size: 4096,
2618            decompressed_size: bytes.len() as u32,
2619            entry_count: 1,
2620        };
2621
2622        assert_eq!(
2623            DirectoryHintTable::parse(&bytes, &locating, 1, MetadataLimits::default()).unwrap_err(),
2624            FormatError::InvalidMetadata {
2625                structure: "DirectoryHintEntry",
2626                reason: "shard count is zero",
2627            }
2628        );
2629    }
2630
2631    #[test]
2632    fn index_shard_rejects_unsupported_version_and_zero_count_pointer_offsets() {
2633        let path = b"file.txt";
2634        let path_hash = hash_prefix(path);
2635        let file = FileEntry {
2636            path_hash,
2637            path_offset: 0,
2638            path_length: path.len() as u32,
2639            first_frame_index: 0,
2640            frame_count: 1,
2641            offset_in_first_frame_plaintext: 0,
2642            tar_member_group_size: 512,
2643            file_data_size: 0,
2644            flags: 0,
2645        };
2646        let frame = FrameEntry {
2647            frame_index: 0,
2648            envelope_index: 0,
2649            offset_in_envelope: 0,
2650            compressed_size: 128,
2651            decompressed_size: 512,
2652            flags: 0,
2653            tar_stream_offset: 0,
2654        };
2655        let envelope = EnvelopeEntry {
2656            envelope_index: 0,
2657            first_block_index: 0,
2658            data_block_count: 1,
2659            parity_block_count: 0,
2660            encrypted_size: 4096,
2661            plaintext_size: 128,
2662            first_frame_index: 0,
2663            frame_count: 1,
2664        };
2665        let shard = IndexShard {
2666            header: IndexShardHeader {
2667                version: 1,
2668                shard_index: 7,
2669                file_count: 0,
2670                frame_count: 0,
2671                envelope_count: 0,
2672                file_table_offset: 0,
2673                frame_table_offset: 0,
2674                envelope_table_offset: 0,
2675                string_pool_offset: 0,
2676                string_pool_size: 0,
2677            },
2678            files: vec![file],
2679            frames: vec![frame],
2680            envelopes: vec![envelope],
2681            string_pool: path.to_vec(),
2682            file_paths: Vec::new(),
2683            file_tar_member_group_starts: Vec::new(),
2684        };
2685        let locating = ShardEntry {
2686            shard_index: 7,
2687            first_block_index: 10,
2688            data_block_count: 1,
2689            parity_block_count: 0,
2690            encrypted_size: 4096,
2691            decompressed_size: shard.to_bytes().len() as u32,
2692            file_count: 1,
2693            first_path_hash: path_hash,
2694            last_path_hash: path_hash,
2695        };
2696
2697        let mut unsupported_version = shard.to_bytes();
2698        write_u32(&mut unsupported_version, 4, 2);
2699        assert_eq!(
2700            IndexShard::parse(&unsupported_version, &locating, MetadataLimits::default())
2701                .unwrap_err(),
2702            FormatError::InvalidMetadata {
2703                structure: "IndexShard",
2704                reason: "unsupported version",
2705            }
2706        );
2707
2708        let mut nonzero_zero_frame_table = shard.to_bytes();
2709        write_u32(&mut nonzero_zero_frame_table, 20, 0);
2710        write_u32(
2711            &mut nonzero_zero_frame_table,
2712            32,
2713            INDEX_SHARD_HEADER_LEN as u32,
2714        );
2715        assert_eq!(
2716            IndexShard::parse(
2717                &nonzero_zero_frame_table,
2718                &locating,
2719                MetadataLimits::default()
2720            )
2721            .unwrap_err(),
2722            FormatError::InvalidMetadata {
2723                structure: "IndexShard",
2724                reason: "absent counted table has non-zero offset",
2725            }
2726        );
2727
2728        let mut nonzero_zero_envelope_table = shard.to_bytes();
2729        write_u32(&mut nonzero_zero_envelope_table, 24, 0);
2730        write_u32(
2731            &mut nonzero_zero_envelope_table,
2732            36,
2733            (INDEX_SHARD_HEADER_LEN + FILE_ENTRY_LEN + FRAME_ENTRY_LEN) as u32,
2734        );
2735        assert_eq!(
2736            IndexShard::parse(
2737                &nonzero_zero_envelope_table,
2738                &locating,
2739                MetadataLimits::default()
2740            )
2741            .unwrap_err(),
2742            FormatError::InvalidMetadata {
2743                structure: "IndexShard",
2744                reason: "absent counted table has non-zero offset",
2745            }
2746        );
2747    }
2748
2749    #[test]
2750    fn directory_hint_table_rejects_zero_count_nonzero_offsets() {
2751        let path = b"dir".to_vec();
2752        let table = DirectoryHintTable {
2753            header: DirectoryHintTableHeader {
2754                version: 1,
2755                hint_shard_index: 5,
2756                entry_count: 0,
2757                entry_table_offset: 0,
2758                shard_list_offset: 0,
2759                string_pool_offset: 0,
2760                string_pool_size: 0,
2761            },
2762            entries: vec![DirectoryHintEntry {
2763                dir_hash: hash_prefix(&path),
2764                path_offset: 0,
2765                path_length: path.len() as u32,
2766                shard_list_start_index: 0,
2767                shard_count: 1,
2768            }],
2769            shard_row_indexes: vec![0],
2770            string_pool: path.clone(),
2771            entry_paths: vec![path.clone()],
2772        };
2773        let locating = DirectoryHintShardEntry {
2774            hint_shard_index: 5,
2775            first_dir_hash: hash_prefix(&path),
2776            last_dir_hash: hash_prefix(&path),
2777            first_block_index: 0,
2778            data_block_count: 1,
2779            parity_block_count: 0,
2780            encrypted_size: 4096,
2781            decompressed_size: table.to_bytes().len() as u32,
2782            entry_count: 1,
2783        };
2784        let mut bytes = table.to_bytes();
2785        let bytes_len = bytes.len() as u64;
2786        write_u64(&mut bytes, 48, 0);
2787        write_u64(&mut bytes, 40, bytes_len);
2788
2789        assert_eq!(
2790            DirectoryHintTable::parse(&bytes, &locating, 1, MetadataLimits::default()).unwrap_err(),
2791            FormatError::InvalidMetadata {
2792                structure: "DirectoryHintTable",
2793                reason: "absent string pool has non-zero offset",
2794            }
2795        );
2796    }
2797
2798    #[test]
2799    fn index_shard_rejects_non_exact_local_frame_and_envelope_tables() {
2800        let path = b"exact-local.txt";
2801        let path_hash = hash_prefix(path);
2802        let file = FileEntry {
2803            path_hash,
2804            path_offset: 0,
2805            path_length: path.len() as u32,
2806            first_frame_index: 0,
2807            frame_count: 1,
2808            offset_in_first_frame_plaintext: 0,
2809            tar_member_group_size: 512,
2810            file_data_size: 0,
2811            flags: 0,
2812        };
2813        let frame = FrameEntry {
2814            frame_index: 0,
2815            envelope_index: 0,
2816            offset_in_envelope: 0,
2817            compressed_size: 128,
2818            decompressed_size: 512,
2819            flags: 0,
2820            tar_stream_offset: 0,
2821        };
2822        let envelope = EnvelopeEntry {
2823            envelope_index: 0,
2824            first_block_index: 10,
2825            data_block_count: 1,
2826            parity_block_count: 0,
2827            encrypted_size: 4096,
2828            plaintext_size: 128,
2829            first_frame_index: 0,
2830            frame_count: 1,
2831        };
2832        let shard = IndexShard {
2833            header: IndexShardHeader {
2834                version: 1,
2835                shard_index: 3,
2836                file_count: 0,
2837                frame_count: 0,
2838                envelope_count: 0,
2839                file_table_offset: 0,
2840                frame_table_offset: 0,
2841                envelope_table_offset: 0,
2842                string_pool_offset: 0,
2843                string_pool_size: 0,
2844            },
2845            files: vec![file.clone()],
2846            frames: vec![frame.clone()],
2847            envelopes: vec![envelope.clone()],
2848            string_pool: path.to_vec(),
2849            file_paths: Vec::new(),
2850            file_tar_member_group_starts: Vec::new(),
2851        };
2852        let locating = ShardEntry {
2853            shard_index: 3,
2854            first_block_index: 20,
2855            data_block_count: 1,
2856            parity_block_count: 0,
2857            encrypted_size: 4096,
2858            decompressed_size: shard.to_bytes().len() as u32,
2859            file_count: 1,
2860            first_path_hash: path_hash,
2861            last_path_hash: path_hash,
2862        };
2863        IndexShard::parse(&shard.to_bytes(), &locating, MetadataLimits::default()).unwrap();
2864
2865        let parse_with = |frames: Vec<FrameEntry>, envelopes: Vec<EnvelopeEntry>| {
2866            let mut mutated = shard.clone();
2867            mutated.frames = frames;
2868            mutated.envelopes = envelopes;
2869            let bytes = mutated.to_bytes();
2870            let locating = ShardEntry {
2871                decompressed_size: bytes.len() as u32,
2872                ..locating.clone()
2873            };
2874            IndexShard::parse(&bytes, &locating, MetadataLimits::default()).unwrap_err()
2875        };
2876
2877        let mut missing_frame = frame.clone();
2878        missing_frame.frame_index = 1;
2879        assert_eq!(
2880            parse_with(vec![missing_frame], vec![envelope.clone()]),
2881            FormatError::InvalidMetadata {
2882                structure: "FileEntry",
2883                reason: "referenced FrameEntry is missing",
2884            }
2885        );
2886
2887        let mut unreferenced_frame = frame.clone();
2888        unreferenced_frame.frame_index = 9;
2889        unreferenced_frame.tar_stream_offset = 1024;
2890        assert_eq!(
2891            parse_with(
2892                vec![frame.clone(), unreferenced_frame],
2893                vec![envelope.clone()]
2894            ),
2895            FormatError::InvalidMetadata {
2896                structure: "IndexShard",
2897                reason: "FrameEntry table is not the exact set referenced by FileEntry rows",
2898            }
2899        );
2900
2901        assert_eq!(
2902            parse_with(vec![frame.clone(), frame.clone()], vec![envelope.clone()]),
2903            FormatError::InvalidMetadata {
2904                structure: "IndexShard",
2905                reason: "FrameEntry rows are not sorted and unique",
2906            }
2907        );
2908
2909        let mut missing_envelope = envelope.clone();
2910        missing_envelope.envelope_index = 1;
2911        assert_eq!(
2912            parse_with(vec![frame.clone()], vec![missing_envelope]),
2913            FormatError::InvalidMetadata {
2914                structure: "FrameEntry",
2915                reason: "referenced EnvelopeEntry is missing",
2916            }
2917        );
2918
2919        let mut unreferenced_envelope = envelope.clone();
2920        unreferenced_envelope.envelope_index = 9;
2921        unreferenced_envelope.first_block_index = 11;
2922        unreferenced_envelope.first_frame_index = 9;
2923        assert_eq!(
2924            parse_with(
2925                vec![frame.clone()],
2926                vec![envelope.clone(), unreferenced_envelope]
2927            ),
2928            FormatError::InvalidMetadata {
2929                structure: "IndexShard",
2930                reason: "EnvelopeEntry table is not the exact set referenced by FrameEntry rows",
2931            }
2932        );
2933
2934        assert_eq!(
2935            parse_with(vec![frame], vec![envelope.clone(), envelope]),
2936            FormatError::InvalidMetadata {
2937                structure: "IndexShard",
2938                reason: "EnvelopeEntry rows are not sorted and unique",
2939            }
2940        );
2941    }
2942
2943    #[test]
2944    fn metadata_parsers_reject_malformed_buffer_corpus() {
2945        let limits = MetadataLimits::default();
2946        let path = b"file.txt";
2947        let path_hash = hash_prefix(path);
2948        let shard_entry = ShardEntry {
2949            shard_index: 0,
2950            first_block_index: 1,
2951            data_block_count: 1,
2952            parity_block_count: 0,
2953            encrypted_size: 4096,
2954            decompressed_size: 0,
2955            file_count: 1,
2956            first_path_hash: path_hash,
2957            last_path_hash: path_hash,
2958        };
2959
2960        let root = IndexRoot {
2961            header: IndexRootHeader {
2962                file_count: 1,
2963                frame_count: 1,
2964                envelope_count: 1,
2965                payload_block_count: 1,
2966                tar_total_size: 512,
2967                ..IndexRootHeader::empty()
2968            },
2969            shards: vec![ShardEntry {
2970                decompressed_size: 256,
2971                ..shard_entry.clone()
2972            }],
2973            directory_hint_shards: Vec::new(),
2974        };
2975        let root_bytes = root.to_bytes();
2976        IndexRoot::parse(&root_bytes, false, limits).unwrap();
2977
2978        assert_eq!(
2979            IndexRoot::parse(&root_bytes[..INDEX_ROOT_LEN - 1], false, limits).unwrap_err(),
2980            FormatError::InvalidMetadata {
2981                structure: "IndexRoot",
2982                reason: "plaintext is shorter than fixed header",
2983            }
2984        );
2985        let mut bad_root = root_bytes.clone();
2986        bad_root[0] ^= 1;
2987        assert_eq!(
2988            IndexRoot::parse(&bad_root, false, limits).unwrap_err(),
2989            FormatError::BadMagic {
2990                structure: "IndexRoot"
2991            }
2992        );
2993        let mut bad_root = root_bytes.clone();
2994        write_u32(&mut bad_root, 4, 2);
2995        assert_eq!(
2996            IndexRoot::parse(&bad_root, false, limits).unwrap_err(),
2997            FormatError::InvalidMetadata {
2998                structure: "IndexRoot",
2999                reason: "unsupported version",
3000            }
3001        );
3002        let mut bad_root = root_bytes.clone();
3003        bad_root[128] = 1;
3004        assert_eq!(
3005            IndexRoot::parse(&bad_root, false, limits).unwrap_err(),
3006            FormatError::NonZeroReserved {
3007                structure: "IndexRoot"
3008            }
3009        );
3010        let mut bad_root = root_bytes.clone();
3011        write_u64(&mut bad_root, 88, (INDEX_ROOT_LEN + 1) as u64);
3012        assert_eq!(
3013            IndexRoot::parse(&bad_root, false, limits).unwrap_err(),
3014            FormatError::InvalidMetadata {
3015                structure: "IndexRoot",
3016                reason: "shard table",
3017            }
3018        );
3019        assert_eq!(
3020            IndexRoot::parse(&root_bytes[..root_bytes.len() - 1], false, limits).unwrap_err(),
3021            FormatError::InvalidMetadata {
3022                structure: "IndexRoot",
3023                reason: "range is out of bounds",
3024            }
3025        );
3026        let mut bad_root = root_bytes.clone();
3027        bad_root.push(0);
3028        assert_eq!(
3029            IndexRoot::parse(&bad_root, false, limits).unwrap_err(),
3030            FormatError::InvalidMetadata {
3031                structure: "IndexRoot",
3032                reason: "plaintext length does not match canonical cursor",
3033            }
3034        );
3035
3036        let file = FileEntry {
3037            path_hash,
3038            path_offset: 0,
3039            path_length: path.len() as u32,
3040            first_frame_index: 0,
3041            frame_count: 1,
3042            offset_in_first_frame_plaintext: 0,
3043            tar_member_group_size: 512,
3044            file_data_size: 0,
3045            flags: 0,
3046        };
3047        let frame = FrameEntry {
3048            frame_index: 0,
3049            envelope_index: 0,
3050            offset_in_envelope: 0,
3051            compressed_size: 128,
3052            decompressed_size: 512,
3053            flags: 0x0000_0003,
3054            tar_stream_offset: 0,
3055        };
3056        let envelope = EnvelopeEntry {
3057            envelope_index: 0,
3058            first_block_index: 1,
3059            data_block_count: 1,
3060            parity_block_count: 0,
3061            encrypted_size: 4096,
3062            plaintext_size: 128,
3063            first_frame_index: 0,
3064            frame_count: 1,
3065        };
3066        let shard = IndexShard {
3067            header: IndexShardHeader {
3068                version: 1,
3069                shard_index: 0,
3070                file_count: 0,
3071                frame_count: 0,
3072                envelope_count: 0,
3073                file_table_offset: 0,
3074                frame_table_offset: 0,
3075                envelope_table_offset: 0,
3076                string_pool_offset: 0,
3077                string_pool_size: 0,
3078            },
3079            files: vec![file],
3080            frames: vec![frame],
3081            envelopes: vec![envelope],
3082            string_pool: path.to_vec(),
3083            file_paths: Vec::new(),
3084            file_tar_member_group_starts: Vec::new(),
3085        };
3086        let shard_bytes = shard.to_bytes();
3087        let locating = ShardEntry {
3088            decompressed_size: shard_bytes.len() as u32,
3089            ..shard_entry
3090        };
3091        IndexShard::parse(&shard_bytes, &locating, limits).unwrap();
3092
3093        assert_eq!(
3094            IndexShard::parse(
3095                &shard_bytes[..INDEX_SHARD_HEADER_LEN - 1],
3096                &locating,
3097                limits
3098            )
3099            .unwrap_err(),
3100            FormatError::InvalidMetadata {
3101                structure: "IndexShard",
3102                reason: "plaintext is shorter than fixed header",
3103            }
3104        );
3105        let mut bad_shard = shard_bytes.clone();
3106        bad_shard[0] ^= 1;
3107        assert_eq!(
3108            IndexShard::parse(&bad_shard, &locating, limits).unwrap_err(),
3109            FormatError::BadMagic {
3110                structure: "IndexShard"
3111            }
3112        );
3113        let mut bad_shard = shard_bytes.clone();
3114        bad_shard[48] = 1;
3115        assert_eq!(
3116            IndexShard::parse(&bad_shard, &locating, limits).unwrap_err(),
3117            FormatError::NonZeroReserved {
3118                structure: "IndexShard"
3119            }
3120        );
3121        let mut bad_shard = shard_bytes.clone();
3122        write_u32(&mut bad_shard, 28, INDEX_SHARD_HEADER_LEN as u32 + 1);
3123        assert_eq!(
3124            IndexShard::parse(&bad_shard, &locating, limits).unwrap_err(),
3125            FormatError::InvalidMetadata {
3126                structure: "IndexShard",
3127                reason: "file table",
3128            }
3129        );
3130        assert_eq!(
3131            IndexShard::parse(&shard_bytes[..shard_bytes.len() - 1], &locating, limits)
3132                .unwrap_err(),
3133            FormatError::InvalidMetadata {
3134                structure: "IndexShard",
3135                reason: "range is out of bounds",
3136            }
3137        );
3138        let mut bad_shard = shard_bytes.clone();
3139        bad_shard.push(0);
3140        assert_eq!(
3141            IndexShard::parse(&bad_shard, &locating, limits).unwrap_err(),
3142            FormatError::InvalidMetadata {
3143                structure: "IndexShard",
3144                reason: "plaintext length does not match canonical cursor",
3145            }
3146        );
3147
3148        let dir_path = b"dir".to_vec();
3149        let dir_hash = hash_prefix(&dir_path);
3150        let table = DirectoryHintTable {
3151            header: DirectoryHintTableHeader {
3152                version: 1,
3153                hint_shard_index: 0,
3154                entry_count: 0,
3155                entry_table_offset: 0,
3156                shard_list_offset: 0,
3157                string_pool_offset: 0,
3158                string_pool_size: 0,
3159            },
3160            entries: vec![DirectoryHintEntry {
3161                dir_hash,
3162                path_offset: 0,
3163                path_length: dir_path.len() as u32,
3164                shard_list_start_index: 0,
3165                shard_count: 1,
3166            }],
3167            shard_row_indexes: vec![0],
3168            string_pool: dir_path.clone(),
3169            entry_paths: Vec::new(),
3170        };
3171        let table_bytes = table.to_bytes();
3172        let locating_hint = DirectoryHintShardEntry {
3173            hint_shard_index: 0,
3174            first_dir_hash: dir_hash,
3175            last_dir_hash: dir_hash,
3176            first_block_index: 2,
3177            data_block_count: 1,
3178            parity_block_count: 0,
3179            encrypted_size: 4096,
3180            decompressed_size: table_bytes.len() as u32,
3181            entry_count: 1,
3182        };
3183        DirectoryHintTable::parse(&table_bytes, &locating_hint, 1, limits).unwrap();
3184
3185        assert_eq!(
3186            DirectoryHintTable::parse(
3187                &table_bytes[..DIRECTORY_HINT_TABLE_LEN - 1],
3188                &locating_hint,
3189                1,
3190                limits,
3191            )
3192            .unwrap_err(),
3193            FormatError::InvalidMetadata {
3194                structure: "DirectoryHintTable",
3195                reason: "plaintext is shorter than fixed header",
3196            }
3197        );
3198        let mut bad_table = table_bytes.clone();
3199        bad_table[0] ^= 1;
3200        assert_eq!(
3201            DirectoryHintTable::parse(&bad_table, &locating_hint, 1, limits).unwrap_err(),
3202            FormatError::BadMagic {
3203                structure: "DirectoryHintTable"
3204            }
3205        );
3206        let mut bad_table = table_bytes.clone();
3207        bad_table[56] = 1;
3208        assert_eq!(
3209            DirectoryHintTable::parse(&bad_table, &locating_hint, 1, limits).unwrap_err(),
3210            FormatError::NonZeroReserved {
3211                structure: "DirectoryHintTable"
3212            }
3213        );
3214        let mut bad_table = table_bytes.clone();
3215        write_u64(&mut bad_table, 24, DIRECTORY_HINT_TABLE_LEN as u64 + 1);
3216        assert_eq!(
3217            DirectoryHintTable::parse(&bad_table, &locating_hint, 1, limits).unwrap_err(),
3218            FormatError::InvalidMetadata {
3219                structure: "DirectoryHintTable",
3220                reason: "entry table",
3221            }
3222        );
3223        assert_eq!(
3224            DirectoryHintTable::parse(
3225                &table_bytes[..table_bytes.len() - 1],
3226                &locating_hint,
3227                1,
3228                limits
3229            )
3230            .unwrap_err(),
3231            FormatError::InvalidMetadata {
3232                structure: "DirectoryHintTable",
3233                reason: "range is out of bounds",
3234            }
3235        );
3236        let mut bad_table = table_bytes.clone();
3237        bad_table.push(0);
3238        assert_eq!(
3239            DirectoryHintTable::parse(&bad_table, &locating_hint, 1, limits).unwrap_err(),
3240            FormatError::InvalidMetadata {
3241                structure: "DirectoryHintTable",
3242                reason: "plaintext length does not match canonical cursor",
3243            }
3244        );
3245    }
3246
3247    #[test]
3248    fn candidate_path_lookup_uses_supplied_collision_cap() {
3249        let path = b"same-prefix.txt";
3250        let hash = hash_prefix(path);
3251        let root = IndexRoot {
3252            header: IndexRootHeader::empty(),
3253            shards: (0..3)
3254                .map(|idx| ShardEntry {
3255                    shard_index: idx,
3256                    first_block_index: idx,
3257                    data_block_count: 1,
3258                    parity_block_count: 1,
3259                    encrypted_size: 4096,
3260                    decompressed_size: 256,
3261                    file_count: 1,
3262                    first_path_hash: hash,
3263                    last_path_hash: hash,
3264                })
3265                .collect(),
3266            directory_hint_shards: Vec::new(),
3267        };
3268
3269        let mut limits = MetadataLimits {
3270            max_hash_collision_shard_scan: 0,
3271            ..MetadataLimits::default()
3272        };
3273        assert_eq!(
3274            root.candidate_shards_for_path(path, limits).unwrap_err(),
3275            FormatError::HashPrefixCollisionRunExceeded
3276        );
3277
3278        limits.max_hash_collision_shard_scan = 2;
3279        assert_eq!(
3280            root.candidate_shards_for_path(path, limits).unwrap(),
3281            vec![0, 1, 2]
3282        );
3283    }
3284
3285    #[test]
3286    fn parses_single_shard_and_finds_final_file_entry() {
3287        let path = b"file.txt";
3288        let path_hash = hash_prefix(path);
3289        let file = FileEntry {
3290            path_hash,
3291            path_offset: 0,
3292            path_length: path.len() as u32,
3293            first_frame_index: 0,
3294            frame_count: 1,
3295            offset_in_first_frame_plaintext: 0,
3296            tar_member_group_size: 512,
3297            file_data_size: 0,
3298            flags: 0,
3299        };
3300        let frame = FrameEntry {
3301            frame_index: 0,
3302            envelope_index: 0,
3303            offset_in_envelope: 0,
3304            compressed_size: 128,
3305            decompressed_size: 512,
3306            flags: 0,
3307            tar_stream_offset: 0,
3308        };
3309        let envelope = EnvelopeEntry {
3310            envelope_index: 0,
3311            first_block_index: 0,
3312            data_block_count: 1,
3313            parity_block_count: 1,
3314            encrypted_size: 4096,
3315            plaintext_size: 128,
3316            first_frame_index: 0,
3317            frame_count: 1,
3318        };
3319        let shard = IndexShard {
3320            header: IndexShardHeader {
3321                version: 1,
3322                shard_index: 7,
3323                file_count: 0,
3324                frame_count: 0,
3325                envelope_count: 0,
3326                file_table_offset: 0,
3327                frame_table_offset: 0,
3328                envelope_table_offset: 0,
3329                string_pool_offset: 0,
3330                string_pool_size: 0,
3331            },
3332            files: vec![file],
3333            frames: vec![frame],
3334            envelopes: vec![envelope],
3335            string_pool: path.to_vec(),
3336            file_paths: Vec::new(),
3337            file_tar_member_group_starts: Vec::new(),
3338        };
3339        let locating = ShardEntry {
3340            shard_index: 7,
3341            first_block_index: 10,
3342            data_block_count: 1,
3343            parity_block_count: 1,
3344            encrypted_size: 4096,
3345            decompressed_size: shard.to_bytes().len() as u32,
3346            file_count: 1,
3347            first_path_hash: path_hash,
3348            last_path_hash: path_hash,
3349        };
3350
3351        let parsed =
3352            IndexShard::parse(&shard.to_bytes(), &locating, MetadataLimits::default()).unwrap();
3353
3354        assert_eq!(parsed.lookup_file_index(path), Some(0));
3355        assert_eq!(parsed.file_path(0), Some(path.as_slice()));
3356    }
3357}