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