@file-viewer/renderer-chm 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ //! ITSF/ITSP container, PMGL directory, and reset-aware MSCompressed reader.
2
+ //!
3
+ //! The layout follows the public CHM format description. LZX decoding is adapted from
4
+ //! the MIT clean-room RustChm/FastChm reader; unlike their CLI reader, this module keeps
5
+ //! extraction bounded to one reset window and caches only five windows.
6
+
7
+ use std::collections::{HashMap, VecDeque};
8
+
9
+ use crate::{
10
+ error::{ParseError, ParseResult},
11
+ lzx::decompress_reset_window,
12
+ };
13
+
14
+ const ITSF_V2_LEN: usize = 0x58;
15
+ const ITSF_V3_LEN: usize = 0x60;
16
+ const ITSP_LEN: usize = 0x54;
17
+ const PMGL_HEADER_LEN: usize = 0x14;
18
+ const MAX_DIRECTORY_CHUNK: usize = 1024 * 1024;
19
+ const MAX_PATH_BYTES: usize = 4096;
20
+ const CACHE_WINDOWS: usize = 5;
21
+ const LZX_FRAME_LEN: u64 = 0x8000;
22
+
23
+ const PATH_RESET_TABLE: &str = "::DataSpace/Storage/MSCompressed/Transform/{7FC28940-9D31-11D0-9B27-00A0C91E9C7C}/InstanceData/ResetTable";
24
+ const PATH_CONTROL_DATA: &str = "::DataSpace/Storage/MSCompressed/ControlData";
25
+ const PATH_SPAN_INFO: &str = "::DataSpace/Storage/MSCompressed/SpanInfo";
26
+ const PATH_CONTENT: &str = "::DataSpace/Storage/MSCompressed/Content";
27
+
28
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29
+ pub enum EntryKind {
30
+ File,
31
+ Directory,
32
+ }
33
+
34
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
+ pub enum EntryCategory {
36
+ Normal,
37
+ Special,
38
+ Metadata,
39
+ }
40
+
41
+ #[derive(Debug, Clone)]
42
+ pub struct Entry {
43
+ pub path: String,
44
+ pub length: u64,
45
+ pub kind: EntryKind,
46
+ pub category: EntryCategory,
47
+ section: u32,
48
+ offset: u64,
49
+ }
50
+
51
+ impl Entry {
52
+ #[must_use]
53
+ pub fn is_file(&self) -> bool {
54
+ self.kind == EntryKind::File
55
+ }
56
+
57
+ #[must_use]
58
+ pub fn is_directory(&self) -> bool {
59
+ self.kind == EntryKind::Directory
60
+ }
61
+
62
+ #[must_use]
63
+ pub fn is_compressed(&self) -> bool {
64
+ self.section == 1
65
+ }
66
+ }
67
+
68
+ struct CompressionState {
69
+ content_start: usize,
70
+ compressed_len: usize,
71
+ uncompressed_len: u64,
72
+ reset_interval: u32,
73
+ window_bits: u32,
74
+ frame_len: u64,
75
+ frame_offsets: Vec<u64>,
76
+ cache: VecDeque<(u64, Vec<u8>)>,
77
+ }
78
+
79
+ impl CompressionState {
80
+ fn read(&mut self, file: &[u8], start: u64, length: u64) -> ParseResult<Vec<u8>> {
81
+ let end = start.checked_add(length).ok_or(ParseError::Overflow)?;
82
+ if end > self.uncompressed_len {
83
+ return Err(ParseError::Compression(
84
+ "entry exceeds the uncompressed section",
85
+ ));
86
+ }
87
+ let capacity = usize::try_from(length).map_err(|_| ParseError::Overflow)?;
88
+ let mut output = Vec::with_capacity(capacity);
89
+ let interval = u64::from(self.reset_interval);
90
+ let mut position = start;
91
+ while position < end {
92
+ let window_index = position / interval;
93
+ self.ensure_window(file, window_index)?;
94
+ let window_start = window_index
95
+ .checked_mul(interval)
96
+ .ok_or(ParseError::Overflow)?;
97
+ let offset =
98
+ usize::try_from(position - window_start).map_err(|_| ParseError::Overflow)?;
99
+ let available =
100
+ usize::try_from((end - position).min(interval - (position - window_start)))
101
+ .map_err(|_| ParseError::Overflow)?;
102
+ let data = self
103
+ .cache
104
+ .iter()
105
+ .find(|(index, _)| *index == window_index)
106
+ .map(|(_, data)| data)
107
+ .ok_or(ParseError::Compression(
108
+ "decoded reset window was not cached",
109
+ ))?;
110
+ let slice = data
111
+ .get(offset..offset + available)
112
+ .ok_or(ParseError::Compression(
113
+ "entry slice exceeds decoded reset window",
114
+ ))?;
115
+ output.extend_from_slice(slice);
116
+ position += available as u64;
117
+ }
118
+ Ok(output)
119
+ }
120
+
121
+ fn ensure_window(&mut self, file: &[u8], window_index: u64) -> ParseResult<()> {
122
+ if let Some(position) = self
123
+ .cache
124
+ .iter()
125
+ .position(|(index, _)| *index == window_index)
126
+ {
127
+ if let Some(hit) = self.cache.remove(position) {
128
+ self.cache.push_back(hit);
129
+ }
130
+ return Ok(());
131
+ }
132
+ let uncompressed_start = window_index
133
+ .checked_mul(u64::from(self.reset_interval))
134
+ .ok_or(ParseError::Overflow)?;
135
+ if uncompressed_start >= self.uncompressed_len {
136
+ return Err(ParseError::Compression(
137
+ "reset window is outside the section",
138
+ ));
139
+ }
140
+ let frame_index = usize::try_from(uncompressed_start / self.frame_len)
141
+ .map_err(|_| ParseError::Overflow)?;
142
+ let compressed_offset = *self
143
+ .frame_offsets
144
+ .get(frame_index)
145
+ .ok_or(ParseError::Compression("reset table has no frame offset"))?;
146
+ if compressed_offset > self.compressed_len as u64 {
147
+ return Err(ParseError::Compression(
148
+ "reset offset exceeds compressed content",
149
+ ));
150
+ }
151
+ let compressed_start = self
152
+ .content_start
153
+ .checked_add(usize::try_from(compressed_offset).map_err(|_| ParseError::Overflow)?)
154
+ .ok_or(ParseError::Overflow)?;
155
+ let compressed_end = self
156
+ .content_start
157
+ .checked_add(self.compressed_len)
158
+ .ok_or(ParseError::Overflow)?;
159
+ let compressed = file
160
+ .get(compressed_start..compressed_end)
161
+ .ok_or(ParseError::Compression("compressed content is truncated"))?;
162
+ let output_len =
163
+ (self.uncompressed_len - uncompressed_start).min(u64::from(self.reset_interval));
164
+ let decoded = decompress_reset_window(
165
+ compressed,
166
+ output_len,
167
+ self.reset_interval,
168
+ self.window_bits,
169
+ uncompressed_start,
170
+ )?;
171
+ if self.cache.len() == CACHE_WINDOWS {
172
+ self.cache.pop_front();
173
+ }
174
+ self.cache.push_back((window_index, decoded));
175
+ Ok(())
176
+ }
177
+ }
178
+
179
+ pub struct ChmFile {
180
+ file: Vec<u8>,
181
+ content_offset: u64,
182
+ entries: Vec<Entry>,
183
+ by_path: HashMap<String, usize>,
184
+ compression: Option<CompressionState>,
185
+ }
186
+
187
+ impl ChmFile {
188
+ pub fn from_bytes(
189
+ file: Vec<u8>,
190
+ max_entries: usize,
191
+ max_total_path_bytes: usize,
192
+ max_metadata_bytes: u64,
193
+ max_uncompressed_bytes: u64,
194
+ ) -> ParseResult<Self> {
195
+ let header = parse_itsf(&file)?;
196
+ let entries = parse_directory(
197
+ &file,
198
+ header.directory_offset,
199
+ header.directory_len,
200
+ max_entries,
201
+ max_total_path_bytes,
202
+ )?;
203
+ let mut by_path = HashMap::with_capacity(entries.len());
204
+ for (index, entry) in entries.iter().enumerate() {
205
+ by_path
206
+ .entry(entry.path.to_ascii_lowercase())
207
+ .or_insert(index);
208
+ }
209
+ let mut chm = Self {
210
+ file,
211
+ content_offset: header.content_offset,
212
+ entries,
213
+ by_path,
214
+ compression: None,
215
+ };
216
+ chm.compression = chm.parse_compression(max_metadata_bytes, max_uncompressed_bytes)?;
217
+ Ok(chm)
218
+ }
219
+
220
+ #[must_use]
221
+ pub fn entries(&self) -> &[Entry] {
222
+ &self.entries
223
+ }
224
+
225
+ #[must_use]
226
+ pub fn has_compression(&self) -> bool {
227
+ self.compression.is_some()
228
+ }
229
+
230
+ pub fn read(&mut self, entry: &Entry) -> ParseResult<Vec<u8>> {
231
+ if entry.length == 0 {
232
+ return Ok(Vec::new());
233
+ }
234
+ match entry.section {
235
+ 0 => self.read_raw(entry),
236
+ 1 => self
237
+ .compression
238
+ .as_mut()
239
+ .ok_or(ParseError::Compression("archive has no usable LZX section"))?
240
+ .read(&self.file, entry.offset, entry.length),
241
+ section => Err(ParseError::UnsupportedSection(section)),
242
+ }
243
+ }
244
+
245
+ pub fn find(&self, path: &str) -> ParseResult<&Entry> {
246
+ let index = self
247
+ .by_path
248
+ .get(&path.to_ascii_lowercase())
249
+ .copied()
250
+ .ok_or_else(|| ParseError::NotFound(path.to_owned()))?;
251
+ self.entries
252
+ .get(index)
253
+ .ok_or(ParseError::Directory("entry index is out of range"))
254
+ }
255
+
256
+ fn read_raw(&self, entry: &Entry) -> ParseResult<Vec<u8>> {
257
+ if entry.section != 0 {
258
+ return Err(ParseError::Compression("control entry is not uncompressed"));
259
+ }
260
+ let start = self
261
+ .content_offset
262
+ .checked_add(entry.offset)
263
+ .ok_or(ParseError::Overflow)?;
264
+ let end = start
265
+ .checked_add(entry.length)
266
+ .ok_or(ParseError::Overflow)?;
267
+ let start = usize::try_from(start).map_err(|_| ParseError::Overflow)?;
268
+ let end = usize::try_from(end).map_err(|_| ParseError::Overflow)?;
269
+ self.file
270
+ .get(start..end)
271
+ .map(ToOwned::to_owned)
272
+ .ok_or(ParseError::Directory("raw entry exceeds the file"))
273
+ }
274
+
275
+ fn parse_compression(
276
+ &self,
277
+ max_metadata_bytes: u64,
278
+ max_uncompressed_bytes: u64,
279
+ ) -> ParseResult<Option<CompressionState>> {
280
+ let Some(content) = self.find_optional(PATH_CONTENT) else {
281
+ return Ok(None);
282
+ };
283
+ let (Some(control), Some(span), Some(reset_table)) = (
284
+ self.find_optional(PATH_CONTROL_DATA),
285
+ self.find_optional(PATH_SPAN_INFO),
286
+ self.find_optional(PATH_RESET_TABLE),
287
+ ) else {
288
+ return Ok(None);
289
+ };
290
+ if [content, control, span, reset_table]
291
+ .iter()
292
+ .any(|entry| entry.section != 0)
293
+ {
294
+ return Err(ParseError::Compression(
295
+ "LZX control streams must be uncompressed",
296
+ ));
297
+ }
298
+ for (name, entry) in [
299
+ ("ControlData", control),
300
+ ("SpanInfo", span),
301
+ ("ResetTable", reset_table),
302
+ ] {
303
+ if entry.length > max_metadata_bytes {
304
+ return Err(ParseError::ResourceLimit(format!(
305
+ "{name} is {} bytes; maxMetadataBytes is {max_metadata_bytes}",
306
+ entry.length
307
+ )));
308
+ }
309
+ }
310
+ let control_data = self.read_raw(control)?;
311
+ if control_data.len() < 0x18 || control_data.get(4..8) != Some(b"LZXC") {
312
+ return Err(ParseError::Compression("invalid ControlData"));
313
+ }
314
+ let version = read_u32(&control_data, 8)?;
315
+ let mut reset_interval = read_u32(&control_data, 0x0c)?;
316
+ let mut window_size = read_u32(&control_data, 0x10)?;
317
+ if version == 2 {
318
+ reset_interval = reset_interval
319
+ .checked_mul(0x8000)
320
+ .ok_or(ParseError::Overflow)?;
321
+ window_size = window_size
322
+ .checked_mul(0x8000)
323
+ .ok_or(ParseError::Overflow)?;
324
+ } else if version != 1 {
325
+ return Err(ParseError::Compression("unsupported LZXC version"));
326
+ }
327
+ if !(0x8000..=16 * 1024 * 1024).contains(&reset_interval)
328
+ || !(0x8000..=0x20_0000).contains(&window_size)
329
+ || !window_size.is_power_of_two()
330
+ {
331
+ return Err(ParseError::Compression("invalid LZX window/reset geometry"));
332
+ }
333
+
334
+ let span_data = self.read_raw(span)?;
335
+ let uncompressed_len = read_u64(&span_data, 0)?;
336
+ if uncompressed_len > max_uncompressed_bytes {
337
+ return Err(ParseError::ResourceLimit(format!(
338
+ "compressed section declares {uncompressed_len} bytes; maxTotalDecompressedBytes is {max_uncompressed_bytes}"
339
+ )));
340
+ }
341
+ let reset_data = self.read_raw(reset_table)?;
342
+ if reset_data.len() < 0x28 || read_u32(&reset_data, 0)? != 2 {
343
+ return Err(ParseError::Compression("invalid ResetTable header"));
344
+ }
345
+ let frame_count =
346
+ usize::try_from(read_u32(&reset_data, 4)?).map_err(|_| ParseError::Overflow)?;
347
+ let table_offset =
348
+ usize::try_from(read_u32(&reset_data, 0x0c)?).map_err(|_| ParseError::Overflow)?;
349
+ let reset_uncompressed_len = read_u64(&reset_data, 0x10)?;
350
+ let compressed_len = read_u64(&reset_data, 0x18)?;
351
+ let frame_len = read_u64(&reset_data, 0x20)?;
352
+ if frame_count == 0
353
+ || frame_len != LZX_FRAME_LEN
354
+ || frame_len > u64::from(window_size)
355
+ || u64::from(reset_interval) % frame_len != 0
356
+ {
357
+ return Err(ParseError::Compression("invalid reset frame geometry"));
358
+ }
359
+ if reset_uncompressed_len != 0 && reset_uncompressed_len < uncompressed_len {
360
+ return Err(ParseError::Compression(
361
+ "ResetTable span is shorter than SpanInfo",
362
+ ));
363
+ }
364
+ if reset_uncompressed_len > max_uncompressed_bytes {
365
+ return Err(ParseError::ResourceLimit(format!(
366
+ "ResetTable declares {reset_uncompressed_len} bytes; maxTotalDecompressedBytes is {max_uncompressed_bytes}"
367
+ )));
368
+ }
369
+ if compressed_len > content.length {
370
+ return Err(ParseError::Compression(
371
+ "compressed length exceeds Content entry",
372
+ ));
373
+ }
374
+ let max_frame_count = max_uncompressed_bytes.div_ceil(frame_len).saturating_add(1);
375
+ if frame_count as u64 > max_frame_count {
376
+ return Err(ParseError::ResourceLimit(format!(
377
+ "ResetTable contains {frame_count} offsets; safety limit is {max_frame_count}"
378
+ )));
379
+ }
380
+ let table_bytes = frame_count.checked_mul(8).ok_or(ParseError::Overflow)?;
381
+ let table_end = table_offset
382
+ .checked_add(table_bytes)
383
+ .ok_or(ParseError::Overflow)?;
384
+ if table_end > reset_data.len() {
385
+ return Err(ParseError::Compression("reset offsets are truncated"));
386
+ }
387
+ let mut frame_offsets = Vec::with_capacity(frame_count);
388
+ for index in 0..frame_count {
389
+ frame_offsets.push(read_u64(&reset_data, table_offset + index * 8)?);
390
+ }
391
+ if frame_offsets.first().copied().unwrap_or(1) != 0
392
+ || frame_offsets.windows(2).any(|pair| pair[0] > pair[1])
393
+ || frame_offsets.last().copied().unwrap_or(0) > compressed_len
394
+ {
395
+ return Err(ParseError::Compression("reset offsets are not monotonic"));
396
+ }
397
+ let needed_frames = uncompressed_len.div_ceil(frame_len);
398
+ if (frame_count as u64) < needed_frames {
399
+ return Err(ParseError::Compression(
400
+ "reset table has too few frame offsets",
401
+ ));
402
+ }
403
+ let content_start = self
404
+ .content_offset
405
+ .checked_add(content.offset)
406
+ .ok_or(ParseError::Overflow)?;
407
+ let content_start = usize::try_from(content_start).map_err(|_| ParseError::Overflow)?;
408
+ let compressed_len = usize::try_from(compressed_len).map_err(|_| ParseError::Overflow)?;
409
+ let content_end = content_start
410
+ .checked_add(compressed_len)
411
+ .ok_or(ParseError::Overflow)?;
412
+ if content_end > self.file.len() {
413
+ return Err(ParseError::Compression("Content entry exceeds the file"));
414
+ }
415
+ let window_bits = window_size.trailing_zeros();
416
+ Ok(Some(CompressionState {
417
+ content_start,
418
+ compressed_len,
419
+ uncompressed_len,
420
+ reset_interval,
421
+ window_bits,
422
+ frame_len,
423
+ frame_offsets,
424
+ cache: VecDeque::new(),
425
+ }))
426
+ }
427
+
428
+ fn find_optional(&self, path: &str) -> Option<&Entry> {
429
+ self.by_path
430
+ .get(&path.to_ascii_lowercase())
431
+ .and_then(|index| self.entries.get(*index))
432
+ }
433
+ }
434
+
435
+ struct ItsfHeader {
436
+ directory_offset: u64,
437
+ directory_len: u64,
438
+ content_offset: u64,
439
+ }
440
+
441
+ fn parse_itsf(file: &[u8]) -> ParseResult<ItsfHeader> {
442
+ if file.len() < ITSF_V2_LEN || file.get(..4) != Some(b"ITSF") {
443
+ return Err(ParseError::Header("missing ITSF signature"));
444
+ }
445
+ let version = read_u32(file, 4)?;
446
+ let header_len = usize::try_from(read_u32(file, 8)?).map_err(|_| ParseError::Overflow)?;
447
+ let minimum = match version {
448
+ 2 => ITSF_V2_LEN,
449
+ 3 => ITSF_V3_LEN,
450
+ _ => return Err(ParseError::Header("unsupported ITSF version")),
451
+ };
452
+ if header_len < minimum || file.len() < minimum {
453
+ return Err(ParseError::Header("truncated ITSF header"));
454
+ }
455
+ let directory_offset = read_u64(file, 0x48)?;
456
+ let directory_len = read_u64(file, 0x50)?;
457
+ let content_offset = if version == 3 {
458
+ read_u64(file, 0x58)?
459
+ } else {
460
+ directory_offset
461
+ .checked_add(directory_len)
462
+ .ok_or(ParseError::Overflow)?
463
+ };
464
+ let directory_end = directory_offset
465
+ .checked_add(directory_len)
466
+ .ok_or(ParseError::Overflow)?;
467
+ if directory_end > file.len() as u64 || content_offset > file.len() as u64 {
468
+ return Err(ParseError::Header(
469
+ "directory/content offset exceeds the file",
470
+ ));
471
+ }
472
+ Ok(ItsfHeader {
473
+ directory_offset,
474
+ directory_len,
475
+ content_offset,
476
+ })
477
+ }
478
+
479
+ fn parse_directory(
480
+ file: &[u8],
481
+ offset: u64,
482
+ length: u64,
483
+ max_entries: usize,
484
+ max_total_path_bytes: usize,
485
+ ) -> ParseResult<Vec<Entry>> {
486
+ let start = usize::try_from(offset).map_err(|_| ParseError::Overflow)?;
487
+ let directory_end = usize::try_from(offset.checked_add(length).ok_or(ParseError::Overflow)?)
488
+ .map_err(|_| ParseError::Overflow)?;
489
+ let header = file
490
+ .get(start..start + ITSP_LEN)
491
+ .ok_or(ParseError::Directory("truncated ITSP header"))?;
492
+ if header.get(..4) != Some(b"ITSP") || read_u32(header, 4)? != 1 {
493
+ return Err(ParseError::Directory("invalid ITSP signature/version"));
494
+ }
495
+ let header_len = usize::try_from(read_u32(header, 8)?).map_err(|_| ParseError::Overflow)?;
496
+ if header_len < ITSP_LEN {
497
+ return Err(ParseError::Directory("invalid ITSP header length"));
498
+ }
499
+ let chunk_len = usize::try_from(read_u32(header, 0x10)?).map_err(|_| ParseError::Overflow)?;
500
+ let chunk_count = usize::try_from(read_u32(header, 0x2c)?).map_err(|_| ParseError::Overflow)?;
501
+ if !(PMGL_HEADER_LEN..=MAX_DIRECTORY_CHUNK).contains(&chunk_len) {
502
+ return Err(ParseError::Directory("invalid directory chunk size"));
503
+ }
504
+ let chunks_start = start.checked_add(header_len).ok_or(ParseError::Overflow)?;
505
+ let chunks_bytes = chunk_count
506
+ .checked_mul(chunk_len)
507
+ .ok_or(ParseError::Overflow)?;
508
+ let chunks_end = chunks_start
509
+ .checked_add(chunks_bytes)
510
+ .ok_or(ParseError::Overflow)?;
511
+ if chunks_end > directory_end || chunks_end > file.len() {
512
+ return Err(ParseError::Directory(
513
+ "directory chunks exceed the ITSF directory span",
514
+ ));
515
+ }
516
+
517
+ let mut entries = Vec::new();
518
+ let mut total_path_bytes = 0usize;
519
+ for chunk_index in 0..chunk_count {
520
+ let chunk_start = chunks_start + chunk_index * chunk_len;
521
+ let chunk = &file[chunk_start..chunk_start + chunk_len];
522
+ if chunk.get(..4) != Some(b"PMGL") {
523
+ continue; // PMGI index chunks contain no leaf entries.
524
+ }
525
+ let free = usize::try_from(read_u32(chunk, 4)?).map_err(|_| ParseError::Overflow)?;
526
+ let used_end = chunk_len
527
+ .checked_sub(free)
528
+ .ok_or(ParseError::Directory("PMGL free space exceeds chunk"))?;
529
+ if used_end < PMGL_HEADER_LEN {
530
+ return Err(ParseError::Directory("PMGL used range is invalid"));
531
+ }
532
+ let mut position = PMGL_HEADER_LEN;
533
+ while position < used_end {
534
+ let path_len = usize::try_from(read_encint(chunk, &mut position, used_end)?)
535
+ .map_err(|_| ParseError::Overflow)?;
536
+ if path_len > MAX_PATH_BYTES {
537
+ return Err(ParseError::Directory("entry path exceeds 4096 bytes"));
538
+ }
539
+ if entries.len() >= max_entries {
540
+ return Err(ParseError::ResourceLimit(format!(
541
+ "directory contains more than {max_entries} entries"
542
+ )));
543
+ }
544
+ let path_end = position.checked_add(path_len).ok_or(ParseError::Overflow)?;
545
+ let path_bytes = chunk
546
+ .get(position..path_end)
547
+ .ok_or(ParseError::Directory("entry path is truncated"))?;
548
+ position = path_end;
549
+ let section = u32::try_from(read_encint(chunk, &mut position, used_end)?)
550
+ .map_err(|_| ParseError::Directory("storage section exceeds u32"))?;
551
+ let entry_offset = read_encint(chunk, &mut position, used_end)?;
552
+ let entry_length = read_encint(chunk, &mut position, used_end)?;
553
+ let path =
554
+ decode_directory_path(path_bytes, &mut total_path_bytes, max_total_path_bytes)?;
555
+ let kind = if path.ends_with('/') {
556
+ EntryKind::Directory
557
+ } else {
558
+ EntryKind::File
559
+ };
560
+ let category = if path.starts_with("/#") || path.starts_with("/$") {
561
+ EntryCategory::Special
562
+ } else if path.starts_with('/') {
563
+ EntryCategory::Normal
564
+ } else {
565
+ EntryCategory::Metadata
566
+ };
567
+ entries.push(Entry {
568
+ path,
569
+ length: entry_length,
570
+ kind,
571
+ category,
572
+ section,
573
+ offset: entry_offset,
574
+ });
575
+ }
576
+ }
577
+ Ok(entries)
578
+ }
579
+
580
+ fn decode_directory_path(
581
+ path_bytes: &[u8],
582
+ total_path_bytes: &mut usize,
583
+ max_total_path_bytes: usize,
584
+ ) -> ParseResult<String> {
585
+ // One path is capped at 4096 raw bytes, so decoding it before charging cannot
586
+ // create an unbounded transient. Charge the actual UTF-8 size before retaining
587
+ // the String in the directory or cloning it into the lookup map.
588
+ let path = String::from_utf8_lossy(path_bytes).into_owned();
589
+ let next = total_path_bytes
590
+ .checked_add(path.len())
591
+ .ok_or(ParseError::Overflow)?;
592
+ if next > max_total_path_bytes {
593
+ return Err(ParseError::ResourceLimit(format!(
594
+ "decoded directory paths exceed {max_total_path_bytes} bytes"
595
+ )));
596
+ }
597
+ *total_path_bytes = next;
598
+ Ok(path)
599
+ }
600
+
601
+ fn read_encint(data: &[u8], position: &mut usize, end: usize) -> ParseResult<u64> {
602
+ let mut value = 0u64;
603
+ for _ in 0..10 {
604
+ if *position >= end {
605
+ return Err(ParseError::Directory("unterminated encoded integer"));
606
+ }
607
+ let byte = *data
608
+ .get(*position)
609
+ .ok_or(ParseError::Directory("encoded integer exceeds chunk"))?;
610
+ *position += 1;
611
+ if value > (u64::MAX >> 7) {
612
+ return Err(ParseError::Overflow);
613
+ }
614
+ value = (value << 7) | u64::from(byte & 0x7f);
615
+ if byte & 0x80 == 0 {
616
+ return Ok(value);
617
+ }
618
+ }
619
+ Err(ParseError::Directory("encoded integer is too long"))
620
+ }
621
+
622
+ fn read_u32(data: &[u8], offset: usize) -> ParseResult<u32> {
623
+ let bytes = data
624
+ .get(offset..offset + 4)
625
+ .ok_or(ParseError::Header("truncated 32-bit field"))?;
626
+ Ok(u32::from_le_bytes(bytes.try_into().expect("fixed slice")))
627
+ }
628
+
629
+ fn read_u64(data: &[u8], offset: usize) -> ParseResult<u64> {
630
+ let bytes = data
631
+ .get(offset..offset + 8)
632
+ .ok_or(ParseError::Header("truncated 64-bit field"))?;
633
+ Ok(u64::from_le_bytes(bytes.try_into().expect("fixed slice")))
634
+ }
635
+
636
+ #[cfg(test)]
637
+ mod tests {
638
+ use super::*;
639
+
640
+ #[test]
641
+ fn encoded_integer_is_bounded() {
642
+ let mut position = 0;
643
+ assert_eq!(read_encint(&[0x81, 0x01], &mut position, 2).unwrap(), 129);
644
+ let mut position = 0;
645
+ assert!(read_encint(&[0x80; 10], &mut position, 10).is_err());
646
+ }
647
+
648
+ #[test]
649
+ fn malformed_header_never_panics() {
650
+ for length in 0..ITSF_V3_LEN {
651
+ assert!(ChmFile::from_bytes(vec![0; length], 100, 4096, 4096, 1024 * 1024).is_err());
652
+ }
653
+ }
654
+
655
+ #[test]
656
+ fn directory_budget_charges_lossy_utf8_expansion() {
657
+ let invalid = vec![0xff; MAX_PATH_BYTES];
658
+ let mut total = 0;
659
+ assert!(matches!(
660
+ decode_directory_path(&invalid, &mut total, MAX_PATH_BYTES),
661
+ Err(ParseError::ResourceLimit(_))
662
+ ));
663
+ assert_eq!(total, 0);
664
+
665
+ let mut total = 0;
666
+ let decoded = decode_directory_path(&invalid, &mut total, MAX_PATH_BYTES * 3).unwrap();
667
+ assert_eq!(decoded.len(), MAX_PATH_BYTES * 3);
668
+ assert_eq!(total, decoded.len());
669
+ }
670
+
671
+ #[test]
672
+ fn large_directory_lookup_uses_case_insensitive_index() {
673
+ let entries = (0..50_000)
674
+ .map(|index| Entry {
675
+ path: format!("/topic-{index}.html"),
676
+ length: 1,
677
+ kind: EntryKind::File,
678
+ category: EntryCategory::Normal,
679
+ section: 0,
680
+ offset: 0,
681
+ })
682
+ .collect::<Vec<_>>();
683
+ let by_path = entries
684
+ .iter()
685
+ .enumerate()
686
+ .map(|(index, entry)| (entry.path.to_ascii_lowercase(), index))
687
+ .collect();
688
+ let chm = ChmFile {
689
+ file: Vec::new(),
690
+ content_offset: 0,
691
+ entries,
692
+ by_path,
693
+ compression: None,
694
+ };
695
+ for _ in 0..10_000 {
696
+ assert_eq!(
697
+ chm.find("/TOPIC-49999.HTML").unwrap().path,
698
+ "/topic-49999.html"
699
+ );
700
+ }
701
+ }
702
+ }