@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/core.rs
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
//! Bounded archive facade and renderer manifest construction.
|
|
2
|
+
|
|
3
|
+
use std::collections::HashMap;
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
|
|
7
|
+
use crate::{
|
|
8
|
+
chm::{ChmFile, Entry, EntryCategory, EntryKind},
|
|
9
|
+
error::{CoreError, CoreResult},
|
|
10
|
+
metadata::{
|
|
11
|
+
FullTextMetadata, IndexNode, MetadataStringBudget, SystemInfo, TopicTables,
|
|
12
|
+
encoding_for_lcid, language_for_lcid, parse_binary_index, parse_binary_toc,
|
|
13
|
+
parse_full_text_metadata, parse_system,
|
|
14
|
+
},
|
|
15
|
+
sitemap::{SitemapNode, parse_sitemap},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const MIB: u64 = 1024 * 1024;
|
|
19
|
+
type TopicTableBytes = (Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>);
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
22
|
+
#[serde(default, rename_all = "camelCase")]
|
|
23
|
+
pub struct Limits {
|
|
24
|
+
pub max_archive_bytes: u64,
|
|
25
|
+
/// Backward-compatible WASM caller field. When both are supplied, the stricter
|
|
26
|
+
/// value wins instead of silently relaxing a limit.
|
|
27
|
+
pub max_file_bytes: Option<u64>,
|
|
28
|
+
pub max_entries: usize,
|
|
29
|
+
pub max_entry_bytes: u64,
|
|
30
|
+
pub max_total_decompressed_bytes: u64,
|
|
31
|
+
pub max_total_declared_bytes: Option<u64>,
|
|
32
|
+
pub max_metadata_bytes: u64,
|
|
33
|
+
pub max_sitemap_nodes: usize,
|
|
34
|
+
pub max_sitemap_depth: usize,
|
|
35
|
+
pub max_directory_path_bytes: usize,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl Default for Limits {
|
|
39
|
+
fn default() -> Self {
|
|
40
|
+
Self {
|
|
41
|
+
max_archive_bytes: 256 * MIB,
|
|
42
|
+
max_file_bytes: None,
|
|
43
|
+
max_entries: 100_000,
|
|
44
|
+
max_entry_bytes: 96 * MIB,
|
|
45
|
+
max_total_decompressed_bytes: 2 * 1024 * MIB,
|
|
46
|
+
max_total_declared_bytes: None,
|
|
47
|
+
max_metadata_bytes: 32 * MIB,
|
|
48
|
+
max_sitemap_nodes: 50_000,
|
|
49
|
+
max_sitemap_depth: 256,
|
|
50
|
+
max_directory_path_bytes: 8 * MIB as usize,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl Limits {
|
|
56
|
+
fn validate(&self) -> CoreResult<()> {
|
|
57
|
+
if self.max_archive_bytes == 0
|
|
58
|
+
|| self.max_entries == 0
|
|
59
|
+
|| self.max_entry_bytes == 0
|
|
60
|
+
|| self.max_total_decompressed_bytes == 0
|
|
61
|
+
|| self.max_metadata_bytes == 0
|
|
62
|
+
|| self.max_sitemap_nodes == 0
|
|
63
|
+
|| self.max_sitemap_depth == 0
|
|
64
|
+
|| self.max_directory_path_bytes == 0
|
|
65
|
+
{
|
|
66
|
+
return Err(CoreError::Limit("all CHM limits must be non-zero".into()));
|
|
67
|
+
}
|
|
68
|
+
if self.effective_archive_bytes() > 1024 * MIB {
|
|
69
|
+
return Err(CoreError::Limit(
|
|
70
|
+
"maxArchiveBytes cannot exceed the 1 GiB WASM safety ceiling".into(),
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
if self.max_sitemap_depth > 1024 {
|
|
74
|
+
return Err(CoreError::Limit(
|
|
75
|
+
"maxSitemapDepth cannot exceed 1024".into(),
|
|
76
|
+
));
|
|
77
|
+
}
|
|
78
|
+
if self.max_entries > 250_000
|
|
79
|
+
|| self.max_sitemap_nodes > 250_000
|
|
80
|
+
|| self.max_directory_path_bytes > 16 * MIB as usize
|
|
81
|
+
|| self.max_metadata_bytes > 64 * MIB
|
|
82
|
+
|| self.max_entry_bytes > 512 * MIB
|
|
83
|
+
|| self.effective_total_bytes() > 8 * 1024 * MIB
|
|
84
|
+
{
|
|
85
|
+
return Err(CoreError::Limit(
|
|
86
|
+
"configured CHM limits exceed the WASM hard safety ceilings".into(),
|
|
87
|
+
));
|
|
88
|
+
}
|
|
89
|
+
Ok(())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn effective_archive_bytes(&self) -> u64 {
|
|
93
|
+
self.max_file_bytes
|
|
94
|
+
.map_or(self.max_archive_bytes, |legacy| {
|
|
95
|
+
legacy.min(self.max_archive_bytes)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn effective_total_bytes(&self) -> u64 {
|
|
100
|
+
self.max_total_declared_bytes
|
|
101
|
+
.map_or(self.max_total_decompressed_bytes, |legacy| {
|
|
102
|
+
legacy.min(self.max_total_decompressed_bytes)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[derive(Debug, Clone, Serialize)]
|
|
108
|
+
#[serde(rename_all = "camelCase")]
|
|
109
|
+
pub struct ArchiveEntry {
|
|
110
|
+
pub path: String,
|
|
111
|
+
pub byte_length: u64,
|
|
112
|
+
pub compressed: bool,
|
|
113
|
+
pub kind: &'static str,
|
|
114
|
+
pub category: &'static str,
|
|
115
|
+
pub media_type: String,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
impl ArchiveEntry {
|
|
119
|
+
fn from_entry(entry: &Entry) -> Self {
|
|
120
|
+
Self {
|
|
121
|
+
path: entry.path.clone(),
|
|
122
|
+
byte_length: entry.length,
|
|
123
|
+
compressed: entry.is_compressed(),
|
|
124
|
+
kind: match entry.kind {
|
|
125
|
+
EntryKind::File => "file",
|
|
126
|
+
EntryKind::Directory => "directory",
|
|
127
|
+
},
|
|
128
|
+
category: match entry.category {
|
|
129
|
+
EntryCategory::Normal => "normal",
|
|
130
|
+
EntryCategory::Special => "special",
|
|
131
|
+
EntryCategory::Metadata => "metadata",
|
|
132
|
+
},
|
|
133
|
+
media_type: media_type(&entry.path).into(),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[derive(Debug, Clone, Serialize)]
|
|
139
|
+
#[serde(rename_all = "camelCase")]
|
|
140
|
+
pub struct Topic {
|
|
141
|
+
pub path: String,
|
|
142
|
+
pub title: String,
|
|
143
|
+
pub byte_length: u64,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[derive(Debug, Clone, Serialize)]
|
|
147
|
+
#[serde(rename_all = "camelCase")]
|
|
148
|
+
pub struct Manifest {
|
|
149
|
+
pub format_version: u32,
|
|
150
|
+
pub title: String,
|
|
151
|
+
pub home_path: String,
|
|
152
|
+
pub encoding: String,
|
|
153
|
+
pub language: String,
|
|
154
|
+
pub lcid: u32,
|
|
155
|
+
pub compressed: bool,
|
|
156
|
+
pub topics: Vec<Topic>,
|
|
157
|
+
pub contents: Vec<SitemapNode>,
|
|
158
|
+
pub index: Vec<IndexNode>,
|
|
159
|
+
pub has_binary_toc: bool,
|
|
160
|
+
pub has_binary_index: bool,
|
|
161
|
+
pub full_text_index: FullTextMetadata,
|
|
162
|
+
pub merged_archives: Vec<String>,
|
|
163
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
164
|
+
pub warnings: Vec<String>,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub struct ArchiveCore {
|
|
168
|
+
chm: ChmFile,
|
|
169
|
+
public_entries: Vec<ArchiveEntry>,
|
|
170
|
+
limits: Limits,
|
|
171
|
+
manifest: Option<Manifest>,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
impl ArchiveCore {
|
|
175
|
+
pub fn open(bytes: Vec<u8>, limits: Limits) -> CoreResult<Self> {
|
|
176
|
+
limits.validate()?;
|
|
177
|
+
let file_len = u64::try_from(bytes.len())
|
|
178
|
+
.map_err(|_| CoreError::Limit("CHM size does not fit u64".into()))?;
|
|
179
|
+
if file_len > limits.effective_archive_bytes() {
|
|
180
|
+
return Err(CoreError::Limit(format!(
|
|
181
|
+
"archive is {file_len} bytes; maxArchiveBytes is {}",
|
|
182
|
+
limits.effective_archive_bytes()
|
|
183
|
+
)));
|
|
184
|
+
}
|
|
185
|
+
let chm = ChmFile::from_bytes(
|
|
186
|
+
bytes,
|
|
187
|
+
limits.max_entries,
|
|
188
|
+
limits.max_directory_path_bytes,
|
|
189
|
+
limits.max_metadata_bytes,
|
|
190
|
+
limits.effective_total_bytes(),
|
|
191
|
+
)?;
|
|
192
|
+
let entries = chm.entries();
|
|
193
|
+
if entries.len() > limits.max_entries {
|
|
194
|
+
return Err(CoreError::Limit(format!(
|
|
195
|
+
"archive has {} entries; maxEntries is {}",
|
|
196
|
+
entries.len(),
|
|
197
|
+
limits.max_entries
|
|
198
|
+
)));
|
|
199
|
+
}
|
|
200
|
+
let mut total = 0u64;
|
|
201
|
+
for entry in entries
|
|
202
|
+
.iter()
|
|
203
|
+
.filter(|entry| matches!(entry.category, EntryCategory::Normal))
|
|
204
|
+
{
|
|
205
|
+
total = total
|
|
206
|
+
.checked_add(entry.length)
|
|
207
|
+
.ok_or_else(|| CoreError::Limit("declared entry sizes overflow u64".into()))?;
|
|
208
|
+
if total > limits.effective_total_bytes() {
|
|
209
|
+
return Err(CoreError::Limit(format!(
|
|
210
|
+
"declared content exceeds maxTotalDecompressedBytes ({})",
|
|
211
|
+
limits.effective_total_bytes()
|
|
212
|
+
)));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
let public_entries = entries.iter().map(ArchiveEntry::from_entry).collect();
|
|
216
|
+
Ok(Self {
|
|
217
|
+
chm,
|
|
218
|
+
public_entries,
|
|
219
|
+
limits,
|
|
220
|
+
manifest: None,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[must_use]
|
|
225
|
+
pub fn entries(&self) -> &[ArchiveEntry] {
|
|
226
|
+
&self.public_entries
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pub fn read(&mut self, path: &str) -> CoreResult<Vec<u8>> {
|
|
230
|
+
let path = normalize_member_path(path)?;
|
|
231
|
+
let entry = self.chm.find(&path)?.clone();
|
|
232
|
+
if entry.is_directory() {
|
|
233
|
+
return Err(CoreError::UnsafePath(format!(
|
|
234
|
+
"entry is a directory: {path}"
|
|
235
|
+
)));
|
|
236
|
+
}
|
|
237
|
+
if entry.length > self.limits.max_entry_bytes {
|
|
238
|
+
return Err(CoreError::Limit(format!(
|
|
239
|
+
"entry {path} is {} bytes; maxEntryBytes is {}",
|
|
240
|
+
entry.length, self.limits.max_entry_bytes
|
|
241
|
+
)));
|
|
242
|
+
}
|
|
243
|
+
let output = self.chm.read(&entry)?;
|
|
244
|
+
if output.len() as u64 > self.limits.max_entry_bytes {
|
|
245
|
+
return Err(CoreError::Limit(format!(
|
|
246
|
+
"decoded entry {path} exceeded maxEntryBytes"
|
|
247
|
+
)));
|
|
248
|
+
}
|
|
249
|
+
Ok(output)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pub fn manifest(&mut self) -> CoreResult<&Manifest> {
|
|
253
|
+
if self.manifest.is_none() {
|
|
254
|
+
self.manifest = Some(self.build_manifest()?);
|
|
255
|
+
}
|
|
256
|
+
Ok(self.manifest.as_ref().expect("manifest was initialized"))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn build_manifest(&mut self) -> CoreResult<Manifest> {
|
|
260
|
+
let mut warnings = Vec::new();
|
|
261
|
+
let system = match self.read_metadata_optional("/#SYSTEM") {
|
|
262
|
+
Ok(Some(bytes)) => match parse_system(&bytes) {
|
|
263
|
+
Ok(system) => system,
|
|
264
|
+
Err(error) => {
|
|
265
|
+
warnings.push(format!("ignored malformed /#SYSTEM: {error}"));
|
|
266
|
+
SystemInfo::default()
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
Ok(None) => SystemInfo::default(),
|
|
270
|
+
Err(error) => {
|
|
271
|
+
warnings.push(format!("could not read /#SYSTEM: {error}"));
|
|
272
|
+
SystemInfo::default()
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
let fallback_encoding = encoding_for_lcid(system.lcid);
|
|
276
|
+
let mut selected_encoding = fallback_encoding.name().to_owned();
|
|
277
|
+
|
|
278
|
+
let contents_path = self.select_metadata_path(system.contents_file.as_deref(), "hhc");
|
|
279
|
+
let mut contents = Vec::new();
|
|
280
|
+
if let Some(path) = contents_path.as_deref() {
|
|
281
|
+
match self.read_metadata_optional(path) {
|
|
282
|
+
Ok(Some(bytes)) => match parse_sitemap(
|
|
283
|
+
&bytes,
|
|
284
|
+
fallback_encoding,
|
|
285
|
+
self.limits.max_sitemap_nodes,
|
|
286
|
+
self.limits.max_sitemap_depth,
|
|
287
|
+
) {
|
|
288
|
+
Ok((mut nodes, encoding)) => {
|
|
289
|
+
selected_encoding = encoding;
|
|
290
|
+
normalize_sitemap_paths(&mut nodes);
|
|
291
|
+
contents = nodes;
|
|
292
|
+
}
|
|
293
|
+
Err(error) => warnings.push(format!(
|
|
294
|
+
"ignored malformed contents sitemap {path}: {error}"
|
|
295
|
+
)),
|
|
296
|
+
},
|
|
297
|
+
Ok(None) => {}
|
|
298
|
+
Err(error) => {
|
|
299
|
+
warnings.push(format!("could not read contents sitemap {path}: {error}"))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let index_path = self.select_metadata_path(system.index_file.as_deref(), "hhk");
|
|
305
|
+
let mut index = Vec::new();
|
|
306
|
+
if let Some(path) = index_path.as_deref() {
|
|
307
|
+
match self.read_metadata_optional(path) {
|
|
308
|
+
Ok(Some(bytes)) => match parse_sitemap(
|
|
309
|
+
&bytes,
|
|
310
|
+
fallback_encoding,
|
|
311
|
+
self.limits.max_sitemap_nodes,
|
|
312
|
+
self.limits.max_sitemap_depth,
|
|
313
|
+
) {
|
|
314
|
+
Ok((mut nodes, encoding)) => {
|
|
315
|
+
if contents.is_empty() {
|
|
316
|
+
selected_encoding = encoding;
|
|
317
|
+
}
|
|
318
|
+
normalize_sitemap_paths(&mut nodes);
|
|
319
|
+
index = nodes.into_iter().map(index_from_sitemap).collect();
|
|
320
|
+
}
|
|
321
|
+
Err(error) => {
|
|
322
|
+
warnings.push(format!("ignored malformed keyword sitemap {path}: {error}"))
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
Ok(None) => {}
|
|
326
|
+
Err(error) => {
|
|
327
|
+
warnings.push(format!("could not read keyword sitemap {path}: {error}"))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let has_binary_toc = self.has_entry("/#TOCIDX");
|
|
333
|
+
let has_binary_index = self.has_entry("/$WWKeywordLinks/BTree");
|
|
334
|
+
let mut binary_string_budget = MetadataStringBudget::new(self.limits.max_metadata_bytes);
|
|
335
|
+
if (contents.is_empty() && has_binary_toc) || (index.is_empty() && has_binary_index) {
|
|
336
|
+
match self.load_topic_tables() {
|
|
337
|
+
Ok(Some((topics, url_table, url_strings, strings))) => {
|
|
338
|
+
let tables = TopicTables {
|
|
339
|
+
topics: &topics,
|
|
340
|
+
url_table: &url_table,
|
|
341
|
+
url_strings: &url_strings,
|
|
342
|
+
strings: &strings,
|
|
343
|
+
encoding: fallback_encoding,
|
|
344
|
+
};
|
|
345
|
+
if contents.is_empty() && has_binary_toc {
|
|
346
|
+
match self.read_metadata_optional("/#TOCIDX") {
|
|
347
|
+
Ok(Some(bytes)) => match parse_binary_toc(
|
|
348
|
+
&bytes,
|
|
349
|
+
&tables,
|
|
350
|
+
self.limits.max_sitemap_nodes,
|
|
351
|
+
self.limits.max_sitemap_depth,
|
|
352
|
+
&mut binary_string_budget,
|
|
353
|
+
) {
|
|
354
|
+
Ok(mut nodes) => {
|
|
355
|
+
normalize_sitemap_paths(&mut nodes);
|
|
356
|
+
contents = nodes;
|
|
357
|
+
}
|
|
358
|
+
Err(error) => {
|
|
359
|
+
warnings.push(format!("ignored malformed binary TOC: {error}"))
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
Ok(None) => {}
|
|
363
|
+
Err(error) => {
|
|
364
|
+
warnings.push(format!("could not read binary TOC: {error}"))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if index.is_empty() && has_binary_index {
|
|
369
|
+
match self.read_metadata_optional("/$WWKeywordLinks/BTree") {
|
|
370
|
+
Ok(Some(bytes)) => match parse_binary_index(
|
|
371
|
+
&bytes,
|
|
372
|
+
&tables,
|
|
373
|
+
self.limits.max_sitemap_nodes,
|
|
374
|
+
self.limits.max_sitemap_depth,
|
|
375
|
+
&mut binary_string_budget,
|
|
376
|
+
) {
|
|
377
|
+
Ok(mut nodes) => {
|
|
378
|
+
normalize_index_paths(&mut nodes);
|
|
379
|
+
index = nodes;
|
|
380
|
+
}
|
|
381
|
+
Err(error) => warnings.push(format!(
|
|
382
|
+
"ignored malformed binary keyword index: {error}"
|
|
383
|
+
)),
|
|
384
|
+
},
|
|
385
|
+
Ok(None) => {}
|
|
386
|
+
Err(error) => warnings
|
|
387
|
+
.push(format!("could not read binary keyword index: {error}")),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
Ok(None) => warnings
|
|
392
|
+
.push("binary navigation streams exist without complete topic tables".into()),
|
|
393
|
+
Err(error) => {
|
|
394
|
+
warnings.push(format!("could not load binary navigation tables: {error}"))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let mut title_by_path = HashMap::new();
|
|
400
|
+
collect_titles(&contents, &mut title_by_path);
|
|
401
|
+
let mut topics: Vec<Topic> = self
|
|
402
|
+
.chm
|
|
403
|
+
.entries()
|
|
404
|
+
.iter()
|
|
405
|
+
.filter(|entry| {
|
|
406
|
+
matches!(entry.category, EntryCategory::Normal)
|
|
407
|
+
&& entry.is_file()
|
|
408
|
+
&& is_html_path(&entry.path)
|
|
409
|
+
})
|
|
410
|
+
.map(|entry| Topic {
|
|
411
|
+
path: entry.path.clone(),
|
|
412
|
+
title: title_by_path
|
|
413
|
+
.get(&entry.path.to_ascii_lowercase())
|
|
414
|
+
.cloned()
|
|
415
|
+
.unwrap_or_else(|| filename_title(&entry.path)),
|
|
416
|
+
byte_length: entry.length,
|
|
417
|
+
})
|
|
418
|
+
.collect();
|
|
419
|
+
topics.sort_by(|left, right| {
|
|
420
|
+
left.path
|
|
421
|
+
.to_ascii_lowercase()
|
|
422
|
+
.cmp(&right.path.to_ascii_lowercase())
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if contents.is_empty() {
|
|
426
|
+
contents = topics
|
|
427
|
+
.iter()
|
|
428
|
+
.map(|topic| SitemapNode {
|
|
429
|
+
name: topic.title.clone(),
|
|
430
|
+
local: Some(topic.path.clone()),
|
|
431
|
+
..SitemapNode::default()
|
|
432
|
+
})
|
|
433
|
+
.collect();
|
|
434
|
+
}
|
|
435
|
+
let home_path = system
|
|
436
|
+
.default_topic
|
|
437
|
+
.as_deref()
|
|
438
|
+
.and_then(|path| normalize_member_path(path).ok())
|
|
439
|
+
.filter(|path| self.has_entry(path))
|
|
440
|
+
.or_else(|| first_local(&contents))
|
|
441
|
+
.filter(|path| self.has_entry(path))
|
|
442
|
+
.or_else(|| topics.first().map(|topic| topic.path.clone()))
|
|
443
|
+
.unwrap_or_default();
|
|
444
|
+
|
|
445
|
+
let full_text_entry = self.find_entry("/$FIftiMain").cloned();
|
|
446
|
+
let full_text_index = if let Some(entry) = full_text_entry {
|
|
447
|
+
let prefix = if entry.length <= self.limits.max_metadata_bytes {
|
|
448
|
+
self.chm.read(&entry).unwrap_or_default()
|
|
449
|
+
} else {
|
|
450
|
+
Vec::new()
|
|
451
|
+
};
|
|
452
|
+
parse_full_text_metadata(&prefix, entry.length)
|
|
453
|
+
} else {
|
|
454
|
+
FullTextMetadata {
|
|
455
|
+
available: false,
|
|
456
|
+
byte_length: 0,
|
|
457
|
+
indexed_topic_count: None,
|
|
458
|
+
total_word_count: None,
|
|
459
|
+
unique_word_count: None,
|
|
460
|
+
code_page: None,
|
|
461
|
+
lcid: None,
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
let mut merged_archives: Vec<String> = self
|
|
466
|
+
.chm
|
|
467
|
+
.entries()
|
|
468
|
+
.iter()
|
|
469
|
+
.filter(|entry| {
|
|
470
|
+
matches!(entry.category, EntryCategory::Normal) && extension(&entry.path) == "chm"
|
|
471
|
+
})
|
|
472
|
+
.map(|entry| entry.path.clone())
|
|
473
|
+
.collect();
|
|
474
|
+
merged_archives.sort();
|
|
475
|
+
merged_archives.dedup();
|
|
476
|
+
|
|
477
|
+
Ok(Manifest {
|
|
478
|
+
format_version: system.version,
|
|
479
|
+
title: system.title.unwrap_or_else(|| "Compiled HTML Help".into()),
|
|
480
|
+
home_path,
|
|
481
|
+
encoding: selected_encoding,
|
|
482
|
+
language: language_for_lcid(system.lcid),
|
|
483
|
+
lcid: system.lcid,
|
|
484
|
+
compressed: self.chm.has_compression(),
|
|
485
|
+
topics,
|
|
486
|
+
contents,
|
|
487
|
+
index,
|
|
488
|
+
has_binary_toc: has_binary_toc || system.binary_toc,
|
|
489
|
+
has_binary_index,
|
|
490
|
+
full_text_index,
|
|
491
|
+
merged_archives,
|
|
492
|
+
warnings,
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
fn read_metadata_optional(&mut self, path: &str) -> CoreResult<Option<Vec<u8>>> {
|
|
497
|
+
let Some(entry) = self.find_entry(path).cloned() else {
|
|
498
|
+
return Ok(None);
|
|
499
|
+
};
|
|
500
|
+
if entry.length > self.limits.max_metadata_bytes {
|
|
501
|
+
return Err(CoreError::Limit(format!(
|
|
502
|
+
"metadata entry {path} is {} bytes; maxMetadataBytes is {}",
|
|
503
|
+
entry.length, self.limits.max_metadata_bytes
|
|
504
|
+
)));
|
|
505
|
+
}
|
|
506
|
+
Ok(Some(self.chm.read(&entry)?))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn load_topic_tables(&mut self) -> CoreResult<Option<TopicTableBytes>> {
|
|
510
|
+
let Some(topics_entry) = self.find_entry("/#TOPICS").cloned() else {
|
|
511
|
+
return Ok(None);
|
|
512
|
+
};
|
|
513
|
+
let Some(url_table_entry) = self.find_entry("/#URLTBL").cloned() else {
|
|
514
|
+
return Ok(None);
|
|
515
|
+
};
|
|
516
|
+
let Some(url_strings_entry) = self.find_entry("/#URLSTR").cloned() else {
|
|
517
|
+
return Ok(None);
|
|
518
|
+
};
|
|
519
|
+
let Some(strings_entry) = self.find_entry("/#STRINGS").cloned() else {
|
|
520
|
+
return Ok(None);
|
|
521
|
+
};
|
|
522
|
+
let total = [
|
|
523
|
+
&topics_entry,
|
|
524
|
+
&url_table_entry,
|
|
525
|
+
&url_strings_entry,
|
|
526
|
+
&strings_entry,
|
|
527
|
+
]
|
|
528
|
+
.into_iter()
|
|
529
|
+
.try_fold(0u64, |total, entry| total.checked_add(entry.length))
|
|
530
|
+
.ok_or_else(|| CoreError::Limit("binary navigation table sizes overflow u64".into()))?;
|
|
531
|
+
if total > self.limits.max_metadata_bytes {
|
|
532
|
+
return Err(CoreError::Limit(format!(
|
|
533
|
+
"binary navigation tables total {total} bytes; maxMetadataBytes is {}",
|
|
534
|
+
self.limits.max_metadata_bytes
|
|
535
|
+
)));
|
|
536
|
+
}
|
|
537
|
+
let topics = self.chm.read(&topics_entry)?;
|
|
538
|
+
let url_table = self.chm.read(&url_table_entry)?;
|
|
539
|
+
let url_strings = self.chm.read(&url_strings_entry)?;
|
|
540
|
+
let strings = self.chm.read(&strings_entry)?;
|
|
541
|
+
Ok(Some((topics, url_table, url_strings, strings)))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
fn select_metadata_path(&self, declared: Option<&str>, extension_name: &str) -> Option<String> {
|
|
545
|
+
declared
|
|
546
|
+
.and_then(|path| normalize_member_path(path).ok())
|
|
547
|
+
.filter(|path| self.has_entry(path))
|
|
548
|
+
.or_else(|| {
|
|
549
|
+
self.chm
|
|
550
|
+
.entries()
|
|
551
|
+
.iter()
|
|
552
|
+
.find(|entry| {
|
|
553
|
+
matches!(entry.category, EntryCategory::Normal)
|
|
554
|
+
&& extension(&entry.path) == extension_name
|
|
555
|
+
})
|
|
556
|
+
.map(|entry| entry.path.clone())
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
fn find_entry(&self, path: &str) -> Option<&Entry> {
|
|
561
|
+
self.chm.find(path).ok()
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
fn has_entry(&self, path: &str) -> bool {
|
|
565
|
+
self.find_entry(path).is_some()
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
fn normalize_sitemap_paths(nodes: &mut [SitemapNode]) {
|
|
570
|
+
for node in nodes {
|
|
571
|
+
if let Some(local) = node.local.take() {
|
|
572
|
+
node.local = normalize_member_path(&local).ok();
|
|
573
|
+
}
|
|
574
|
+
normalize_sitemap_paths(&mut node.children);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fn normalize_index_paths(nodes: &mut [IndexNode]) {
|
|
579
|
+
for node in nodes {
|
|
580
|
+
node.locals = node
|
|
581
|
+
.locals
|
|
582
|
+
.drain(..)
|
|
583
|
+
.filter_map(|local| normalize_member_path(&local).ok())
|
|
584
|
+
.collect();
|
|
585
|
+
normalize_index_paths(&mut node.children);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
fn index_from_sitemap(node: SitemapNode) -> IndexNode {
|
|
590
|
+
IndexNode {
|
|
591
|
+
name: node.name,
|
|
592
|
+
locals: node.local.into_iter().collect(),
|
|
593
|
+
see_also: node.see_also,
|
|
594
|
+
children: node.children.into_iter().map(index_from_sitemap).collect(),
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fn collect_titles(nodes: &[SitemapNode], output: &mut HashMap<String, String>) {
|
|
599
|
+
for node in nodes {
|
|
600
|
+
if let Some(local) = &node.local {
|
|
601
|
+
output
|
|
602
|
+
.entry(local.to_ascii_lowercase())
|
|
603
|
+
.or_insert_with(|| node.name.clone());
|
|
604
|
+
}
|
|
605
|
+
collect_titles(&node.children, output);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
fn first_local(nodes: &[SitemapNode]) -> Option<String> {
|
|
610
|
+
for node in nodes {
|
|
611
|
+
if let Some(local) = &node.local {
|
|
612
|
+
return Some(local.clone());
|
|
613
|
+
}
|
|
614
|
+
if let Some(local) = first_local(&node.children) {
|
|
615
|
+
return Some(local);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
None
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
fn normalize_member_path(input: &str) -> CoreResult<String> {
|
|
622
|
+
if input.is_empty()
|
|
623
|
+
|| input
|
|
624
|
+
.bytes()
|
|
625
|
+
.any(|byte| byte == 0 || (byte < 0x20 && !byte.is_ascii_whitespace()))
|
|
626
|
+
{
|
|
627
|
+
return Err(CoreError::UnsafePath(
|
|
628
|
+
"empty or control-containing member path".into(),
|
|
629
|
+
));
|
|
630
|
+
}
|
|
631
|
+
let mut path = input.trim().replace('\\', "/");
|
|
632
|
+
if let Some(marker) = path.find("::/") {
|
|
633
|
+
path = path[marker + 2..].to_owned();
|
|
634
|
+
}
|
|
635
|
+
if path.starts_with("::DataSpace/") {
|
|
636
|
+
if path.split('/').any(|segment| segment == "..") {
|
|
637
|
+
return Err(CoreError::UnsafePath(
|
|
638
|
+
"metadata path traversal is not allowed".into(),
|
|
639
|
+
));
|
|
640
|
+
}
|
|
641
|
+
return Ok(path);
|
|
642
|
+
}
|
|
643
|
+
if let Some(end) = path.find(['#', '?']) {
|
|
644
|
+
path.truncate(end);
|
|
645
|
+
}
|
|
646
|
+
let mut normalized = String::new();
|
|
647
|
+
for segment in path.split('/') {
|
|
648
|
+
match segment {
|
|
649
|
+
"" | "." => {}
|
|
650
|
+
".." => {
|
|
651
|
+
return Err(CoreError::UnsafePath(
|
|
652
|
+
"path traversal is not allowed".into(),
|
|
653
|
+
));
|
|
654
|
+
}
|
|
655
|
+
_ if segment.contains(':') => {
|
|
656
|
+
return Err(CoreError::UnsafePath(
|
|
657
|
+
"URL schemes and drive paths are not allowed".into(),
|
|
658
|
+
));
|
|
659
|
+
}
|
|
660
|
+
_ => {
|
|
661
|
+
normalized.push('/');
|
|
662
|
+
normalized.push_str(segment);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if normalized.is_empty() {
|
|
667
|
+
return Err(CoreError::UnsafePath(
|
|
668
|
+
"member path resolves to the archive root".into(),
|
|
669
|
+
));
|
|
670
|
+
}
|
|
671
|
+
Ok(normalized)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fn filename_title(path: &str) -> String {
|
|
675
|
+
path.rsplit('/')
|
|
676
|
+
.next()
|
|
677
|
+
.unwrap_or(path)
|
|
678
|
+
.rsplit_once('.')
|
|
679
|
+
.map_or_else(|| path.into(), |(stem, _)| stem.into())
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
fn is_html_path(path: &str) -> bool {
|
|
683
|
+
matches!(extension(path).as_str(), "htm" | "html" | "xhtml" | "shtml")
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
fn extension(path: &str) -> String {
|
|
687
|
+
path.rsplit('.')
|
|
688
|
+
.next()
|
|
689
|
+
.filter(|part| !part.contains('/'))
|
|
690
|
+
.unwrap_or_default()
|
|
691
|
+
.to_ascii_lowercase()
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
fn media_type(path: &str) -> &'static str {
|
|
695
|
+
match extension(path).as_str() {
|
|
696
|
+
"htm" | "html" | "shtml" => "text/html",
|
|
697
|
+
"xhtml" => "application/xhtml+xml",
|
|
698
|
+
"css" => "text/css",
|
|
699
|
+
"js" => "text/javascript",
|
|
700
|
+
"txt" | "hhc" | "hhk" => "text/plain",
|
|
701
|
+
"xml" => "application/xml",
|
|
702
|
+
"png" => "image/png",
|
|
703
|
+
"jpg" | "jpeg" => "image/jpeg",
|
|
704
|
+
"gif" => "image/gif",
|
|
705
|
+
"svg" => "image/svg+xml",
|
|
706
|
+
"bmp" => "image/bmp",
|
|
707
|
+
"webp" => "image/webp",
|
|
708
|
+
"ico" => "image/x-icon",
|
|
709
|
+
"woff" => "font/woff",
|
|
710
|
+
"woff2" => "font/woff2",
|
|
711
|
+
"ttf" => "font/ttf",
|
|
712
|
+
_ => "application/octet-stream",
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
#[cfg(test)]
|
|
717
|
+
mod tests {
|
|
718
|
+
use super::*;
|
|
719
|
+
|
|
720
|
+
#[test]
|
|
721
|
+
fn normalizes_html_help_urls_but_rejects_escape() {
|
|
722
|
+
assert_eq!(
|
|
723
|
+
normalize_member_path("mk:@MSITStore:help.chm::/docs/a.htm#x").unwrap(),
|
|
724
|
+
"/docs/a.htm"
|
|
725
|
+
);
|
|
726
|
+
assert_eq!(normalize_member_path("docs\\a.htm").unwrap(), "/docs/a.htm");
|
|
727
|
+
assert!(matches!(
|
|
728
|
+
normalize_member_path("../secret"),
|
|
729
|
+
Err(CoreError::UnsafePath(_))
|
|
730
|
+
));
|
|
731
|
+
assert!(matches!(
|
|
732
|
+
normalize_member_path("https://example.test/a"),
|
|
733
|
+
Err(CoreError::UnsafePath(_))
|
|
734
|
+
));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
#[test]
|
|
738
|
+
fn limits_cannot_disable_safety_guards() {
|
|
739
|
+
let limits = Limits {
|
|
740
|
+
max_entries: 0,
|
|
741
|
+
..Limits::default()
|
|
742
|
+
};
|
|
743
|
+
assert!(matches!(limits.validate(), Err(CoreError::Limit(_))));
|
|
744
|
+
}
|
|
745
|
+
}
|