@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,839 @@
1
+ //! Microsoft HTML Help metadata decoders used by the viewer manifest.
2
+
3
+ use std::collections::HashSet;
4
+
5
+ use encoding_rs::Encoding;
6
+ use serde::Serialize;
7
+
8
+ use crate::{
9
+ error::{CoreError, CoreResult},
10
+ sitemap::SitemapNode,
11
+ };
12
+
13
+ const MAX_METADATA_FIELD_BYTES: usize = 64 * 1024;
14
+
15
+ /// Shared cap for strings materialized from binary navigation tables. Topic tables
16
+ /// may legally reuse offsets, so input-size checks alone do not bound manifest output.
17
+ pub struct MetadataStringBudget {
18
+ remaining: usize,
19
+ }
20
+
21
+ impl MetadataStringBudget {
22
+ #[must_use]
23
+ pub fn new(limit: u64) -> Self {
24
+ Self {
25
+ remaining: usize::try_from(limit).unwrap_or(usize::MAX),
26
+ }
27
+ }
28
+
29
+ fn charge(&mut self, bytes: usize) -> CoreResult<()> {
30
+ if bytes > self.remaining {
31
+ return Err(CoreError::Limit(
32
+ "binary navigation strings exceed maxMetadataBytes".into(),
33
+ ));
34
+ }
35
+ self.remaining -= bytes;
36
+ Ok(())
37
+ }
38
+ }
39
+
40
+ #[derive(Debug, Clone, Default)]
41
+ pub struct SystemInfo {
42
+ pub version: u32,
43
+ pub lcid: u32,
44
+ pub contents_file: Option<String>,
45
+ pub index_file: Option<String>,
46
+ pub default_topic: Option<String>,
47
+ pub title: Option<String>,
48
+ pub default_window: Option<String>,
49
+ pub default_font: Option<String>,
50
+ pub binary_toc: bool,
51
+ }
52
+
53
+ #[derive(Debug, Clone, Default, Serialize)]
54
+ #[serde(rename_all = "camelCase")]
55
+ pub struct IndexNode {
56
+ pub name: String,
57
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
58
+ pub locals: Vec<String>,
59
+ #[serde(skip_serializing_if = "Option::is_none")]
60
+ pub see_also: Option<String>,
61
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
62
+ pub children: Vec<IndexNode>,
63
+ }
64
+
65
+ #[derive(Debug, Clone, Serialize)]
66
+ #[serde(rename_all = "camelCase")]
67
+ pub struct FullTextMetadata {
68
+ pub available: bool,
69
+ pub byte_length: u64,
70
+ #[serde(skip_serializing_if = "Option::is_none")]
71
+ pub indexed_topic_count: Option<u32>,
72
+ #[serde(skip_serializing_if = "Option::is_none")]
73
+ pub total_word_count: Option<u32>,
74
+ #[serde(skip_serializing_if = "Option::is_none")]
75
+ pub unique_word_count: Option<u32>,
76
+ #[serde(skip_serializing_if = "Option::is_none")]
77
+ pub code_page: Option<u32>,
78
+ #[serde(skip_serializing_if = "Option::is_none")]
79
+ pub lcid: Option<u32>,
80
+ }
81
+
82
+ pub fn parse_system(data: &[u8]) -> CoreResult<SystemInfo> {
83
+ if data.len() < 4 {
84
+ return Err(CoreError::UnsafePath("/#SYSTEM is truncated".into()));
85
+ }
86
+ let mut info = SystemInfo {
87
+ version: le_u32(data, 0)?,
88
+ ..SystemInfo::default()
89
+ };
90
+ let mut pos = 4usize;
91
+ while pos < data.len() {
92
+ let code = le_u16(data, pos)?;
93
+ let length = usize::from(le_u16(data, pos + 2)?);
94
+ pos = pos.checked_add(4).ok_or_else(overflow)?;
95
+ let end = pos.checked_add(length).ok_or_else(overflow)?;
96
+ let value = data
97
+ .get(pos..end)
98
+ .ok_or_else(|| CoreError::UnsafePath("/#SYSTEM record is truncated".into()))?;
99
+ if code == 4 && value.len() >= 4 {
100
+ info.lcid = le_u32(value, 0)?;
101
+ }
102
+ if code == 11 {
103
+ info.binary_toc = true;
104
+ }
105
+ pos = end;
106
+ }
107
+
108
+ let encoding = encoding_for_lcid(info.lcid);
109
+ pos = 4;
110
+ while pos < data.len() {
111
+ let code = le_u16(data, pos)?;
112
+ let length = usize::from(le_u16(data, pos + 2)?);
113
+ pos = pos.checked_add(4).ok_or_else(overflow)?;
114
+ let end = pos.checked_add(length).ok_or_else(overflow)?;
115
+ let value = data
116
+ .get(pos..end)
117
+ .ok_or_else(|| CoreError::UnsafePath("/#SYSTEM record is truncated".into()))?;
118
+ let text = || decode_c_string_field(value, encoding);
119
+ match code {
120
+ 0 => info.contents_file = nonempty(text()?),
121
+ 1 => info.index_file = nonempty(text()?),
122
+ 2 => info.default_topic = nonempty(text()?),
123
+ 3 => info.title = nonempty(text()?),
124
+ 5 => info.default_window = nonempty(text()?),
125
+ 16 => info.default_font = nonempty(text()?),
126
+ _ => {}
127
+ }
128
+ pos = end;
129
+ }
130
+ Ok(info)
131
+ }
132
+
133
+ fn nonempty(value: String) -> Option<String> {
134
+ let value = value.trim().to_owned();
135
+ (!value.is_empty()).then_some(value)
136
+ }
137
+
138
+ pub fn encoding_for_lcid(lcid: u32) -> &'static Encoding {
139
+ let label = match lcid & 0xffff {
140
+ 0x0404 | 0x0c04 | 0x1404 => b"big5".as_slice(),
141
+ 0x0804 | 0x1004 => b"gb18030".as_slice(),
142
+ 0x0411 => b"shift_jis".as_slice(),
143
+ 0x0412 => b"euc-kr".as_slice(),
144
+ 0x0419 | 0x0422 | 0x0423 | 0x042f | 0x0440 | 0x0444 => b"windows-1251".as_slice(),
145
+ 0x0408 => b"windows-1253".as_slice(),
146
+ 0x041f => b"windows-1254".as_slice(),
147
+ 0x040d => b"windows-1255".as_slice(),
148
+ 0x0401 | 0x0801 | 0x0c01 | 0x1001 | 0x1401 | 0x1801 | 0x1c01 | 0x2001 | 0x2401 | 0x2801
149
+ | 0x2c01 | 0x3001 | 0x3401 | 0x3801 | 0x3c01 | 0x4001 => b"windows-1256".as_slice(),
150
+ 0x0425..=0x0427 => b"windows-1257".as_slice(),
151
+ 0x042a => b"windows-1258".as_slice(),
152
+ _ => b"windows-1252".as_slice(),
153
+ };
154
+ Encoding::for_label(label).expect("static encoding label")
155
+ }
156
+
157
+ pub fn language_for_lcid(lcid: u32) -> String {
158
+ match lcid & 0xffff {
159
+ 0x0404 => "zh-TW",
160
+ 0x0804 => "zh-CN",
161
+ 0x0c04 => "zh-HK",
162
+ 0x1004 => "zh-SG",
163
+ 0x1404 => "zh-MO",
164
+ 0x0409 => "en-US",
165
+ 0x0809 => "en-GB",
166
+ 0x0c09 => "en-AU",
167
+ 0x0411 => "ja-JP",
168
+ 0x0412 => "ko-KR",
169
+ 0x0407 => "de-DE",
170
+ 0x040c => "fr-FR",
171
+ 0x0410 => "it-IT",
172
+ 0x0419 => "ru-RU",
173
+ 0x0c0a => "es-ES",
174
+ 0x0416 => "pt-BR",
175
+ 0x0816 => "pt-PT",
176
+ _ => return format!("und-x-lcid-{lcid:04x}"),
177
+ }
178
+ .into()
179
+ }
180
+
181
+ pub struct TopicTables<'a> {
182
+ pub topics: &'a [u8],
183
+ pub url_table: &'a [u8],
184
+ pub url_strings: &'a [u8],
185
+ pub strings: &'a [u8],
186
+ pub encoding: &'static Encoding,
187
+ }
188
+
189
+ impl TopicTables<'_> {
190
+ pub fn resolve(
191
+ &self,
192
+ topic: u32,
193
+ budget: &mut MetadataStringBudget,
194
+ ) -> CoreResult<Option<(String, String)>> {
195
+ let Some((title_offset, url_string_offset)) = self.topic_offsets(topic) else {
196
+ return Ok(None);
197
+ };
198
+ let Some(url) =
199
+ decode_prefixed_url(self.url_strings, url_string_offset, self.encoding, budget)?
200
+ else {
201
+ return Ok(None);
202
+ };
203
+ let title = if title_offset == u32::MAX {
204
+ String::new()
205
+ } else {
206
+ let Some(title) = decode_offset_string(
207
+ self.strings,
208
+ usize::try_from(title_offset).map_err(|_| overflow())?,
209
+ self.encoding,
210
+ budget,
211
+ )?
212
+ else {
213
+ return Ok(None);
214
+ };
215
+ title
216
+ };
217
+ Ok(Some((title, url)))
218
+ }
219
+
220
+ fn local(&self, topic: u32, budget: &mut MetadataStringBudget) -> CoreResult<Option<String>> {
221
+ let Some((_, url_string_offset)) = self.topic_offsets(topic) else {
222
+ return Ok(None);
223
+ };
224
+ decode_prefixed_url(self.url_strings, url_string_offset, self.encoding, budget)
225
+ }
226
+
227
+ fn string(&self, offset: u32, budget: &mut MetadataStringBudget) -> CoreResult<Option<String>> {
228
+ decode_offset_string(
229
+ self.strings,
230
+ usize::try_from(offset).map_err(|_| overflow())?,
231
+ self.encoding,
232
+ budget,
233
+ )
234
+ }
235
+
236
+ fn topic_offsets(&self, topic: u32) -> Option<(u32, usize)> {
237
+ let offset = usize::try_from(topic).ok()?.checked_mul(16)?;
238
+ let record = self.topics.get(offset..offset + 16)?;
239
+ let title_offset = le_u32_opt(record, 4)?;
240
+ let url_table_offset = usize::try_from(le_u32_opt(record, 8)?).ok()?;
241
+ let url_record = self
242
+ .url_table
243
+ .get(url_table_offset..url_table_offset + 12)?;
244
+ let url_string_offset = usize::try_from(le_u32_opt(url_record, 8)?).ok()?;
245
+ Some((title_offset, url_string_offset))
246
+ }
247
+ }
248
+
249
+ pub fn parse_binary_toc(
250
+ toc: &[u8],
251
+ tables: &TopicTables<'_>,
252
+ max_nodes: usize,
253
+ max_depth: usize,
254
+ string_budget: &mut MetadataStringBudget,
255
+ ) -> CoreResult<Vec<SitemapNode>> {
256
+ if toc.len() < 16 {
257
+ return Err(CoreError::UnsafePath("/#TOCIDX is truncated".into()));
258
+ }
259
+ let root = usize::try_from(le_u32(toc, 0)?).map_err(|_| overflow())?;
260
+ let mut visited = HashSet::new();
261
+ let mut count = 0usize;
262
+ parse_toc_siblings(
263
+ toc,
264
+ tables,
265
+ root,
266
+ 0,
267
+ max_depth,
268
+ max_nodes,
269
+ &mut count,
270
+ &mut visited,
271
+ string_budget,
272
+ )
273
+ }
274
+
275
+ #[allow(clippy::too_many_arguments)]
276
+ fn parse_toc_siblings(
277
+ toc: &[u8],
278
+ tables: &TopicTables<'_>,
279
+ mut offset: usize,
280
+ depth: usize,
281
+ max_depth: usize,
282
+ max_nodes: usize,
283
+ count: &mut usize,
284
+ visited: &mut HashSet<usize>,
285
+ string_budget: &mut MetadataStringBudget,
286
+ ) -> CoreResult<Vec<SitemapNode>> {
287
+ if depth >= max_depth {
288
+ return Err(CoreError::Limit(format!(
289
+ "binary TOC nesting exceeds {max_depth}"
290
+ )));
291
+ }
292
+ let mut nodes = Vec::new();
293
+ while offset != 0 {
294
+ if !visited.insert(offset) {
295
+ return Err(CoreError::UnsafePath("binary TOC contains a cycle".into()));
296
+ }
297
+ *count += 1;
298
+ if *count > max_nodes {
299
+ return Err(CoreError::Limit(format!(
300
+ "binary TOC contains more than {max_nodes} nodes"
301
+ )));
302
+ }
303
+ let base = toc
304
+ .get(offset..offset + 20)
305
+ .ok_or_else(|| CoreError::UnsafePath("binary TOC record is truncated".into()))?;
306
+ let properties = le_u32(base, 4)?;
307
+ let reference = le_u32(base, 8)?;
308
+ let next = usize::try_from(le_u32(base, 16)?).map_err(|_| overflow())?;
309
+ let has_children = properties & 4 != 0;
310
+ let has_local = properties & 8 != 0;
311
+ let (mut name, local) = if has_local {
312
+ let (title, url) = tables
313
+ .resolve(reference, string_budget)?
314
+ .unwrap_or_default();
315
+ (title, nonempty(url))
316
+ } else {
317
+ (
318
+ tables.string(reference, string_budget)?.unwrap_or_default(),
319
+ None,
320
+ )
321
+ };
322
+ if name.is_empty() {
323
+ let fallback = local.as_deref().unwrap_or("Untitled");
324
+ string_budget.charge(fallback.len())?;
325
+ name = fallback.to_owned();
326
+ }
327
+ let children = if has_children {
328
+ let child = usize::try_from(le_u32(toc, offset + 20)?).map_err(|_| overflow())?;
329
+ parse_toc_siblings(
330
+ toc,
331
+ tables,
332
+ child,
333
+ depth + 1,
334
+ max_depth,
335
+ max_nodes,
336
+ count,
337
+ visited,
338
+ string_budget,
339
+ )?
340
+ } else {
341
+ Vec::new()
342
+ };
343
+ nodes.push(SitemapNode {
344
+ name,
345
+ local,
346
+ merge: None,
347
+ see_also: None,
348
+ image_number: None,
349
+ children,
350
+ });
351
+ offset = next;
352
+ }
353
+ Ok(nodes)
354
+ }
355
+
356
+ pub fn parse_binary_index(
357
+ btree: &[u8],
358
+ tables: &TopicTables<'_>,
359
+ max_nodes: usize,
360
+ max_depth: usize,
361
+ string_budget: &mut MetadataStringBudget,
362
+ ) -> CoreResult<Vec<IndexNode>> {
363
+ const HEADER_LEN: usize = 0x4c;
364
+ if btree.len() < HEADER_LEN || btree[0..2] != [0x3b, 0x29] {
365
+ return Err(CoreError::UnsafePath(
366
+ "binary keyword index has an invalid header".into(),
367
+ ));
368
+ }
369
+ let block_len = usize::from(le_u16(btree, 4)?);
370
+ if !(20..=0x10_0000).contains(&block_len) {
371
+ return Err(CoreError::UnsafePath(
372
+ "binary keyword index has an invalid block size".into(),
373
+ ));
374
+ }
375
+ let last_list_block = usize::try_from(le_u32(btree, 26)?).map_err(|_| overflow())?;
376
+ let mut flat = Vec::new();
377
+ let mut expanded_nodes = 0usize;
378
+ let mut previous_depth = None;
379
+ for block_index in 0..=last_list_block {
380
+ let start = HEADER_LEN
381
+ .checked_add(block_index.checked_mul(block_len).ok_or_else(overflow)?)
382
+ .ok_or_else(overflow)?;
383
+ let block = btree.get(start..start + block_len).ok_or_else(|| {
384
+ CoreError::UnsafePath("binary keyword index block is truncated".into())
385
+ })?;
386
+ let free = usize::from(le_u16(block, 0)?);
387
+ let entries = usize::from(le_u16(block, 2)?);
388
+ let end = block_len.checked_sub(free).ok_or_else(overflow)?;
389
+ let mut pos = 12usize;
390
+ for _ in 0..entries {
391
+ expanded_nodes = expanded_nodes.checked_add(1).ok_or_else(overflow)?;
392
+ if expanded_nodes > max_nodes {
393
+ return Err(CoreError::Limit(format!(
394
+ "keyword index contains more than {max_nodes} nodes"
395
+ )));
396
+ }
397
+ let (name, next) = read_utf16z(block, pos, end, string_budget)?;
398
+ pos = next;
399
+ let flags = le_u16(block, pos)?;
400
+ let depth = le_u16(block, pos + 2)?;
401
+ let depth_usize = usize::from(depth);
402
+ if depth_usize >= max_depth {
403
+ return Err(CoreError::Limit(format!(
404
+ "binary keyword index nesting exceeds {max_depth}"
405
+ )));
406
+ }
407
+ match previous_depth {
408
+ None if depth != 0 => {
409
+ return Err(CoreError::UnsafePath(
410
+ "binary keyword index does not start at depth zero".into(),
411
+ ));
412
+ }
413
+ Some(previous) if depth_usize > previous + 1 => {
414
+ return Err(CoreError::UnsafePath(
415
+ "binary keyword index contains an invalid depth jump".into(),
416
+ ));
417
+ }
418
+ _ => {}
419
+ }
420
+ previous_depth = Some(depth_usize);
421
+ pos = pos.checked_add(8).ok_or_else(overflow)?; // flags, depth, character index
422
+ pos = pos.checked_add(4).ok_or_else(overflow)?; // reserved
423
+ let count = usize::try_from(le_u32(block, pos)?).map_err(|_| overflow())?;
424
+ pos = pos.checked_add(4).ok_or_else(overflow)?;
425
+ let (locals, see_also) = if flags & 2 != 0 {
426
+ let (target, next) = read_utf16z(block, pos, end, string_budget)?;
427
+ pos = next;
428
+ (Vec::new(), nonempty(target))
429
+ } else {
430
+ if count > max_nodes {
431
+ return Err(CoreError::Limit(
432
+ "keyword topic fan-out exceeds the node limit".into(),
433
+ ));
434
+ }
435
+ expanded_nodes = expanded_nodes.checked_add(count).ok_or_else(overflow)?;
436
+ if expanded_nodes > max_nodes {
437
+ return Err(CoreError::Limit(format!(
438
+ "keyword index and topic references contain more than {max_nodes} nodes"
439
+ )));
440
+ }
441
+ let mut locals = Vec::with_capacity(count);
442
+ let mut seen_topics = HashSet::with_capacity(count);
443
+ for _ in 0..count {
444
+ let topic = le_u32(block, pos)?;
445
+ pos = pos.checked_add(4).ok_or_else(overflow)?;
446
+ if seen_topics.insert(topic)
447
+ && let Some(local) = tables.local(topic, string_budget)?
448
+ {
449
+ locals.push(local);
450
+ }
451
+ }
452
+ (locals, None)
453
+ };
454
+ pos = pos.checked_add(8).ok_or_else(overflow)?;
455
+ if pos > end {
456
+ return Err(CoreError::UnsafePath(
457
+ "binary keyword entry exceeds its block".into(),
458
+ ));
459
+ }
460
+ flat.push((
461
+ depth,
462
+ IndexNode {
463
+ name,
464
+ locals,
465
+ see_also,
466
+ children: Vec::new(),
467
+ },
468
+ ));
469
+ }
470
+ }
471
+ Ok(nest_index(flat))
472
+ }
473
+
474
+ fn nest_index(flat: Vec<(u16, IndexNode)>) -> Vec<IndexNode> {
475
+ let mut roots = Vec::new();
476
+ let mut stack: Vec<(u16, IndexNode)> = Vec::new();
477
+ for (depth, node) in flat {
478
+ while stack
479
+ .last()
480
+ .is_some_and(|(parent_depth, _)| *parent_depth >= depth)
481
+ {
482
+ attach_index_node(&mut roots, &mut stack);
483
+ }
484
+ stack.push((depth, node));
485
+ }
486
+ while !stack.is_empty() {
487
+ attach_index_node(&mut roots, &mut stack);
488
+ }
489
+ roots
490
+ }
491
+
492
+ fn attach_index_node(roots: &mut Vec<IndexNode>, stack: &mut Vec<(u16, IndexNode)>) {
493
+ let (_, node) = stack.pop().expect("stack is not empty");
494
+ if let Some((_, parent)) = stack.last_mut() {
495
+ parent.children.push(node);
496
+ } else {
497
+ roots.push(node);
498
+ }
499
+ }
500
+
501
+ pub fn parse_full_text_metadata(data: &[u8], byte_length: u64) -> FullTextMetadata {
502
+ if data.len() >= 130 && data.get(2) == Some(&0x28) {
503
+ FullTextMetadata {
504
+ available: true,
505
+ byte_length,
506
+ indexed_topic_count: le_u32_opt(data, 4),
507
+ total_word_count: le_u32_opt(data, 66),
508
+ unique_word_count: le_u32_opt(data, 70),
509
+ code_page: le_u32_opt(data, 122),
510
+ lcid: le_u32_opt(data, 126),
511
+ }
512
+ } else {
513
+ FullTextMetadata {
514
+ available: true,
515
+ byte_length,
516
+ indexed_topic_count: None,
517
+ total_word_count: None,
518
+ unique_word_count: None,
519
+ code_page: None,
520
+ lcid: None,
521
+ }
522
+ }
523
+ }
524
+
525
+ fn decode_c_string_field(value: &[u8], encoding: &'static Encoding) -> CoreResult<String> {
526
+ let scan_len = value.len().min(MAX_METADATA_FIELD_BYTES + 1);
527
+ let end = value[..scan_len]
528
+ .iter()
529
+ .position(|&byte| byte == 0)
530
+ .unwrap_or(scan_len);
531
+ if end > MAX_METADATA_FIELD_BYTES || (end == scan_len && value.len() > MAX_METADATA_FIELD_BYTES)
532
+ {
533
+ return Err(CoreError::Limit(format!(
534
+ "metadata string exceeds {MAX_METADATA_FIELD_BYTES} bytes"
535
+ )));
536
+ }
537
+ let (decoded, _, _) = encoding.decode(&value[..end]);
538
+ if decoded.len() > MAX_METADATA_FIELD_BYTES {
539
+ return Err(CoreError::Limit(format!(
540
+ "decoded metadata string exceeds {MAX_METADATA_FIELD_BYTES} bytes"
541
+ )));
542
+ }
543
+ Ok(decoded.into_owned())
544
+ }
545
+
546
+ fn decode_offset_string(
547
+ data: &[u8],
548
+ offset: usize,
549
+ encoding: &'static Encoding,
550
+ budget: &mut MetadataStringBudget,
551
+ ) -> CoreResult<Option<String>> {
552
+ let Some(rest) = data.get(offset..) else {
553
+ return Ok(None);
554
+ };
555
+ let value = decode_c_string_field(rest, encoding)?;
556
+ budget.charge(value.len())?;
557
+ Ok(Some(value))
558
+ }
559
+
560
+ fn decode_prefixed_url(
561
+ data: &[u8],
562
+ offset: usize,
563
+ encoding: &'static Encoding,
564
+ budget: &mut MetadataStringBudget,
565
+ ) -> CoreResult<Option<String>> {
566
+ let Some(offset) = offset.checked_add(8) else {
567
+ return Ok(None);
568
+ };
569
+ decode_offset_string(data, offset, encoding, budget)
570
+ }
571
+
572
+ fn read_utf16z(
573
+ data: &[u8],
574
+ mut pos: usize,
575
+ end: usize,
576
+ budget: &mut MetadataStringBudget,
577
+ ) -> CoreResult<(String, usize)> {
578
+ let start = pos;
579
+ loop {
580
+ if pos + 2 > end {
581
+ return Err(CoreError::UnsafePath("unterminated UTF-16 keyword".into()));
582
+ }
583
+ let unit = le_u16(data, pos)?;
584
+ pos += 2;
585
+ if unit == 0 {
586
+ break;
587
+ }
588
+ if pos - start > MAX_METADATA_FIELD_BYTES {
589
+ return Err(CoreError::Limit(format!(
590
+ "UTF-16 metadata string exceeds {MAX_METADATA_FIELD_BYTES} bytes"
591
+ )));
592
+ }
593
+ }
594
+ let units = data[start..pos - 2]
595
+ .chunks_exact(2)
596
+ .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
597
+ .collect::<Vec<_>>();
598
+ let value = String::from_utf16_lossy(&units);
599
+ if value.len() > MAX_METADATA_FIELD_BYTES {
600
+ return Err(CoreError::Limit(format!(
601
+ "decoded UTF-16 metadata string exceeds {MAX_METADATA_FIELD_BYTES} bytes"
602
+ )));
603
+ }
604
+ budget.charge(value.len())?;
605
+ Ok((value, pos))
606
+ }
607
+
608
+ fn le_u16(data: &[u8], offset: usize) -> CoreResult<u16> {
609
+ let bytes = data
610
+ .get(offset..offset + 2)
611
+ .ok_or_else(|| CoreError::UnsafePath("metadata integer is truncated".into()))?;
612
+ Ok(u16::from_le_bytes([bytes[0], bytes[1]]))
613
+ }
614
+
615
+ fn le_u32(data: &[u8], offset: usize) -> CoreResult<u32> {
616
+ le_u32_opt(data, offset)
617
+ .ok_or_else(|| CoreError::UnsafePath("metadata integer is truncated".into()))
618
+ }
619
+
620
+ fn le_u32_opt(data: &[u8], offset: usize) -> Option<u32> {
621
+ let bytes = data.get(offset..offset + 4)?;
622
+ Some(u32::from_le_bytes(bytes.try_into().ok()?))
623
+ }
624
+
625
+ fn overflow() -> CoreError {
626
+ CoreError::UnsafePath("metadata offset overflow".into())
627
+ }
628
+
629
+ #[cfg(test)]
630
+ mod tests {
631
+ use super::*;
632
+
633
+ #[test]
634
+ fn parses_system_strings_and_language() {
635
+ let mut data = 3u32.to_le_bytes().to_vec();
636
+ data.extend_from_slice(&4u16.to_le_bytes());
637
+ data.extend_from_slice(&4u16.to_le_bytes());
638
+ data.extend_from_slice(&0x0804u32.to_le_bytes());
639
+ data.extend_from_slice(&2u16.to_le_bytes());
640
+ data.extend_from_slice(&11u16.to_le_bytes());
641
+ data.extend_from_slice(b"index.html\0");
642
+ let info = parse_system(&data).unwrap();
643
+ assert_eq!(info.default_topic.as_deref(), Some("index.html"));
644
+ assert_eq!(language_for_lcid(info.lcid), "zh-CN");
645
+ assert_eq!(encoding_for_lcid(info.lcid).name(), "gb18030");
646
+ }
647
+
648
+ #[test]
649
+ fn parses_many_zero_length_system_records_without_per_record_storage() {
650
+ let mut data = 3u32.to_le_bytes().to_vec();
651
+ data.resize(4 + 4 * 100_000, 0);
652
+ let info = parse_system(&data).unwrap();
653
+ assert_eq!(info.version, 3);
654
+ assert!(info.contents_file.is_none());
655
+ assert_eq!(info.lcid, 0);
656
+ }
657
+
658
+ #[test]
659
+ fn nests_keyword_depths() {
660
+ let flat = vec![
661
+ (
662
+ 0,
663
+ IndexNode {
664
+ name: "A".into(),
665
+ ..IndexNode::default()
666
+ },
667
+ ),
668
+ (
669
+ 1,
670
+ IndexNode {
671
+ name: "A1".into(),
672
+ ..IndexNode::default()
673
+ },
674
+ ),
675
+ (
676
+ 0,
677
+ IndexNode {
678
+ name: "B".into(),
679
+ ..IndexNode::default()
680
+ },
681
+ ),
682
+ ];
683
+ let nodes = nest_index(flat);
684
+ assert_eq!(nodes.len(), 2);
685
+ assert_eq!(nodes[0].children[0].name, "A1");
686
+ }
687
+
688
+ fn binary_index_with_depths(depths: &[u16]) -> Vec<u8> {
689
+ const HEADER_LEN: usize = 0x4c;
690
+ const BLOCK_LEN: usize = 32 * 1024;
691
+ const ENTRIES_PER_BLOCK: usize = 1_000;
692
+ let block_count = depths.len().div_ceil(ENTRIES_PER_BLOCK);
693
+ let mut btree = vec![0u8; HEADER_LEN + block_count * BLOCK_LEN];
694
+ btree[0..2].copy_from_slice(&[0x3b, 0x29]);
695
+ btree[4..6].copy_from_slice(&(BLOCK_LEN as u16).to_le_bytes());
696
+ btree[26..30].copy_from_slice(&u32::try_from(block_count - 1).unwrap().to_le_bytes());
697
+
698
+ for (block_index, chunk) in depths.chunks(ENTRIES_PER_BLOCK).enumerate() {
699
+ let start = HEADER_LEN + block_index * BLOCK_LEN;
700
+ let block = &mut btree[start..start + BLOCK_LEN];
701
+ block[2..4].copy_from_slice(&u16::try_from(chunk.len()).unwrap().to_le_bytes());
702
+ let mut pos = 12usize;
703
+ for &depth in chunk {
704
+ block[pos..pos + 4].copy_from_slice(&[b'x', 0, 0, 0]);
705
+ pos += 4;
706
+ block[pos + 2..pos + 4].copy_from_slice(&depth.to_le_bytes());
707
+ pos += 8; // flags, depth, character index
708
+ pos += 4; // reserved
709
+ pos += 4; // zero topic references
710
+ pos += 8; // trailing fields
711
+ }
712
+ block[0..2].copy_from_slice(&u16::try_from(BLOCK_LEN - pos).unwrap().to_le_bytes());
713
+ }
714
+ btree
715
+ }
716
+
717
+ #[test]
718
+ fn rejects_deep_or_discontinuous_binary_keyword_index_before_nesting() {
719
+ let tables = TopicTables {
720
+ topics: &[],
721
+ url_table: &[],
722
+ url_strings: &[],
723
+ strings: &[],
724
+ encoding: encoding_for_lcid(0x0409),
725
+ };
726
+ let depths = (0..50_000).map(|depth| depth as u16).collect::<Vec<_>>();
727
+ let deep = binary_index_with_depths(&depths);
728
+ let mut budget = MetadataStringBudget::new(1024 * 1024);
729
+ let error = parse_binary_index(&deep, &tables, 60_000, 256, &mut budget).unwrap_err();
730
+ assert!(matches!(error, CoreError::Limit(message) if message.contains("nesting")));
731
+
732
+ let discontinuous = binary_index_with_depths(&[0, 2]);
733
+ let mut budget = MetadataStringBudget::new(1024);
734
+ let error = parse_binary_index(&discontinuous, &tables, 10, 256, &mut budget).unwrap_err();
735
+ assert!(matches!(error, CoreError::UnsafePath(message) if message.contains("depth jump")));
736
+ }
737
+
738
+ #[test]
739
+ fn binary_index_budgets_topic_references_before_allocation() {
740
+ const HEADER_LEN: usize = 0x4c;
741
+ const BLOCK_LEN: usize = 96;
742
+ let mut btree = vec![0u8; HEADER_LEN + BLOCK_LEN];
743
+ btree[0..2].copy_from_slice(&[0x3b, 0x29]);
744
+ btree[4..6].copy_from_slice(&(BLOCK_LEN as u16).to_le_bytes());
745
+
746
+ let block = &mut btree[HEADER_LEN..];
747
+ block[2..4].copy_from_slice(&1u16.to_le_bytes());
748
+ let mut pos = 12usize;
749
+ block[pos..pos + 2].copy_from_slice(&(b'k' as u16).to_le_bytes());
750
+ pos += 2;
751
+ block[pos..pos + 2].copy_from_slice(&0u16.to_le_bytes());
752
+ pos += 2;
753
+ pos += 8; // flags, depth, character index
754
+ pos += 4; // reserved
755
+ block[pos..pos + 4].copy_from_slice(&2u32.to_le_bytes());
756
+ pos += 4;
757
+ pos += 8; // two topic references
758
+ pos += 8; // trailing fields
759
+ block[0..2].copy_from_slice(&((BLOCK_LEN - pos) as u16).to_le_bytes());
760
+
761
+ let tables = TopicTables {
762
+ topics: &[],
763
+ url_table: &[],
764
+ url_strings: &[],
765
+ strings: &[],
766
+ encoding: encoding_for_lcid(0x0409),
767
+ };
768
+ let mut budget = MetadataStringBudget::new(1024);
769
+ assert!(matches!(
770
+ parse_binary_index(&btree, &tables, 2, 16, &mut budget),
771
+ Err(CoreError::Limit(_))
772
+ ));
773
+ let mut budget = MetadataStringBudget::new(1024);
774
+ assert_eq!(
775
+ parse_binary_index(&btree, &tables, 3, 16, &mut budget)
776
+ .unwrap()
777
+ .len(),
778
+ 1
779
+ );
780
+ }
781
+
782
+ #[test]
783
+ fn binary_index_repeated_offsets_share_string_budget() {
784
+ const HEADER_LEN: usize = 0x4c;
785
+ const BLOCK_LEN: usize = 256;
786
+ let mut btree = vec![0u8; HEADER_LEN + BLOCK_LEN];
787
+ btree[0..2].copy_from_slice(&[0x3b, 0x29]);
788
+ btree[4..6].copy_from_slice(&(BLOCK_LEN as u16).to_le_bytes());
789
+ let block = &mut btree[HEADER_LEN..];
790
+ block[2..4].copy_from_slice(&2u16.to_le_bytes());
791
+ let mut pos = 12usize;
792
+ for name in ["one", "two"] {
793
+ for unit in name.encode_utf16().chain(std::iter::once(0)) {
794
+ block[pos..pos + 2].copy_from_slice(&unit.to_le_bytes());
795
+ pos += 2;
796
+ }
797
+ pos += 8; // flags, depth, character index
798
+ pos += 4; // reserved
799
+ block[pos..pos + 4].copy_from_slice(&1u32.to_le_bytes());
800
+ pos += 4;
801
+ block[pos..pos + 4].copy_from_slice(&0u32.to_le_bytes());
802
+ pos += 4;
803
+ pos += 8; // trailing fields
804
+ }
805
+ block[0..2].copy_from_slice(&((BLOCK_LEN - pos) as u16).to_le_bytes());
806
+
807
+ let mut topics = vec![0u8; 16];
808
+ topics[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
809
+ let url_table = vec![0u8; 12];
810
+ let mut url_strings = vec![0u8; 8];
811
+ url_strings.extend_from_slice(b"/same/repeated/topic/path/that/is/long.html\0");
812
+ let tables = TopicTables {
813
+ topics: &topics,
814
+ url_table: &url_table,
815
+ url_strings: &url_strings,
816
+ strings: &[],
817
+ encoding: encoding_for_lcid(0x0409),
818
+ };
819
+
820
+ let mut budget = MetadataStringBudget::new(70);
821
+ assert!(matches!(
822
+ parse_binary_index(&btree, &tables, 10, 16, &mut budget),
823
+ Err(CoreError::Limit(_))
824
+ ));
825
+ let mut budget = MetadataStringBudget::new(1024);
826
+ let index = parse_binary_index(&btree, &tables, 10, 16, &mut budget).unwrap();
827
+ assert_eq!(index.len(), 2);
828
+ assert_eq!(index[0].locals, index[1].locals);
829
+ }
830
+
831
+ #[test]
832
+ fn metadata_string_field_is_individually_bounded() {
833
+ let value = vec![b'x'; MAX_METADATA_FIELD_BYTES + 1];
834
+ assert!(matches!(
835
+ decode_c_string_field(&value, encoding_for_lcid(0x0409)),
836
+ Err(CoreError::Limit(_))
837
+ ));
838
+ }
839
+ }