@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.
- package/LICENSE +196 -0
- package/README.en.md +13 -0
- package/README.md +13 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/dist/chm.d.ts +2 -0
- package/dist/chm.js +593 -0
- package/dist/chm.worker.d.ts +1 -0
- package/dist/chm.worker.js +2 -0
- package/dist/chm_wasm.js +583 -0
- package/dist/chm_wasm_bg.wasm +0 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -0
- package/dist/model.d.ts +78 -0
- package/dist/model.js +125 -0
- package/dist/security.d.ts +33 -0
- package/dist/security.js +850 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +26 -0
- package/dist/workerClient.d.ts +32 -0
- package/dist/workerClient.js +157 -0
- package/dist/workerProtocol.d.ts +70 -0
- package/dist/workerProtocol.js +1 -0
- package/file-viewer.capability.json +38 -0
- package/package.json +92 -0
- package/rust/Cargo.lock +247 -0
- package/rust/Cargo.toml +29 -0
- package/rust/LICENSE +196 -0
- package/rust/NOTICE.md +47 -0
- package/rust/THIRD_PARTY_LICENSES.md +365 -0
- package/rust/src/chm.rs +702 -0
- package/rust/src/core.rs +745 -0
- package/rust/src/error.rs +64 -0
- package/rust/src/lib.rs +104 -0
- package/rust/src/lzx.rs +632 -0
- package/rust/src/metadata.rs +839 -0
- package/rust/src/sitemap.rs +442 -0
package/rust/src/chm.rs
ADDED
|
@@ -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
|
+
}
|