@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,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
+ }