1use std::path::Path;
7
8use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
9use crate::document::html::{HtmlBlock, render_blocks_to_pages};
10use crate::error::{Error, Result};
11
12const MAX_BIB_BYTES: u64 = 64 * 1024 * 1024;
13const MAX_BIB_LINES: usize = 1_000_000;
14const MAX_BIB_LINE_BYTES: usize = 1024 * 1024;
15const MAX_BIB_ENTRIES: usize = 100_000;
16const MAX_BIB_RECORDS: usize = 100_000;
17const MAX_BIB_FIELDS_PER_ENTRY: usize = 128;
18const MAX_BIB_KEY_BYTES: usize = 1024;
19const MAX_BIB_FIELD_NAME_BYTES: usize = 128;
20const MAX_BIB_FIELD_VALUE_BYTES: usize = 2 * 1024 * 1024;
21const MAX_BIB_ENTRY_BYTES: usize = 4 * 1024 * 1024;
22const MAX_BIB_NESTING: usize = 64;
23const MAX_BIB_RENDERED_TEXT_BYTES: usize = 32 * 1024 * 1024;
24
25#[derive(Default)]
26struct BibParser<'a> {
27 text: &'a str,
28 index: usize,
29 entry_count: usize,
30 records_processed: usize,
31 rendered_bytes: usize,
32 warnings: Vec<String>,
33 blocks: Vec<HtmlBlock>,
34}
35
36pub(crate) fn convert(
37 path: &Path,
38 options: &ConvertOptions,
39 sink: &mut dyn PageConsumer,
40) -> Result<Vec<String>> {
41 let bytes = read_limited_file(
42 path,
43 options.max_input_bytes.min(MAX_BIB_BYTES),
44 "BibTeX input",
45 )?;
46 let text = String::from_utf8(bytes).map_err(|error| {
47 Error::InvalidInput(format!("BibTeX input is not valid UTF-8: {error}"))
48 })?;
49 let (blocks, warnings) = parse_bibtex_blocks_with_warnings(&text)?;
50 let mut sink = BibPageSink {
51 inner: sink,
52 warnings: &warnings,
53 };
54 render_blocks_to_pages(&blocks, &mut sink, options)?;
55 Ok(warnings)
56}
57
58struct BibPageSink<'a> {
59 inner: &'a mut dyn PageConsumer,
60 warnings: &'a [String],
61}
62
63impl PageConsumer for BibPageSink<'_> {
64 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
65 page.source_format = "bib".into();
66 if page.title.is_empty() {
67 page.title = "BibTeX bibliography".into();
68 }
69 for warning in self.warnings {
70 page.warn(warning.clone());
71 }
72 self.inner.consume(page)
73 }
74}
75
76pub fn parse_bibtex_blocks(text: &str) -> Result<Vec<HtmlBlock>> {
77 parse_bibtex_blocks_with_warnings(text).map(|(blocks, _)| blocks)
78}
79
80pub(crate) fn looks_like_bibtex_prefix(prefix: &[u8]) -> bool {
81 let text = String::from_utf8_lossy(prefix);
82 text.lines().take(100).any(|raw_line| {
83 let line = raw_line.trim_start();
84 let Some(rest) = line.strip_prefix('@') else {
85 return false;
86 };
87 let type_end = rest
88 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
89 .unwrap_or(rest.len());
90 if type_end == 0 {
91 return false;
92 }
93 let after_type = rest[type_end..].trim_start();
94 let Some(open) = after_type
95 .chars()
96 .next()
97 .filter(|character| matches!(character, '{' | '('))
98 else {
99 return false;
100 };
101 let body = after_type.get(open.len_utf8()..).unwrap_or_default();
102 let closing = if open == '{' { '}' } else { ')' };
103 let key = body
104 .split_once(',')
105 .map(|(key, _)| key.trim())
106 .unwrap_or_default();
107 !key.is_empty() && !key.starts_with(closing)
108 })
109}
110
111fn parse_bibtex_blocks_with_warnings(text: &str) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
112 validate_bibtex(text)?;
113 let mut parser = BibParser {
114 text,
115 ..BibParser::default()
116 };
117 parser.blocks.push(HtmlBlock::Heading {
118 level: 1,
119 text: "BibTeX bibliography".into(),
120 });
121 while parser.skip_space_and_comments() {
122 if parser.peek_char() != Some('@') {
123 parser.bump_char();
124 continue;
125 }
126 parser.parse_record()?;
127 }
128 if parser.entry_count == 0 {
129 push_bib_warning(
130 &mut parser.warnings,
131 "BibTeX input contains no supported entries",
132 );
133 }
134 Ok((parser.blocks, parser.warnings))
135}
136
137fn validate_bibtex(text: &str) -> Result<()> {
138 if text.len() as u64 > MAX_BIB_BYTES {
139 return Err(Error::LimitExceeded(format!(
140 "BibTeX input exceeds {MAX_BIB_BYTES} bytes"
141 )));
142 }
143 let mut line_count = 0usize;
144 for line in text.lines() {
145 line_count += 1;
146 if line_count > MAX_BIB_LINES {
147 return Err(Error::LimitExceeded(format!(
148 "BibTeX input exceeds {MAX_BIB_LINES} lines"
149 )));
150 }
151 if line.len() > MAX_BIB_LINE_BYTES {
152 return Err(Error::LimitExceeded(format!(
153 "BibTeX line exceeds {MAX_BIB_LINE_BYTES} bytes"
154 )));
155 }
156 }
157 Ok(())
158}
159
160impl BibParser<'_> {
161 fn parse_record(&mut self) -> Result<()> {
162 self.bump_char(); let kind_start = self.index;
164 while self
165 .peek_char()
166 .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
167 {
168 self.bump_char();
169 }
170 if self.index - kind_start > MAX_BIB_FIELD_NAME_BYTES {
171 return Err(Error::LimitExceeded(format!(
172 "BibTeX entry type exceeds {MAX_BIB_FIELD_NAME_BYTES} bytes"
173 )));
174 }
175 let kind = self.text[kind_start..self.index].to_ascii_lowercase();
176 self.records_processed = self.records_processed.saturating_add(1);
177 if self.records_processed > MAX_BIB_RECORDS {
178 return Err(Error::LimitExceeded(format!(
179 "BibTeX input exceeds {MAX_BIB_RECORDS} records"
180 )));
181 }
182 self.skip_whitespace();
183 let Some(open) = self.bump_char() else {
184 return Err(Error::InvalidInput(
185 "BibTeX record has no opening delimiter".into(),
186 ));
187 };
188 let close = match open {
189 '{' => '}',
190 '(' => ')',
191 _ => {
192 return Err(Error::InvalidInput(format!(
193 "BibTeX record @{kind} must use braces or parentheses"
194 )));
195 }
196 };
197 if kind == "comment" {
198 self.skip_balanced(close)?;
199 return Ok(());
200 }
201 if kind == "preamble" {
202 self.skip_balanced(close)?;
203 push_bib_warning(&mut self.warnings, "BibTeX @preamble content was omitted");
204 return Ok(());
205 }
206 if kind == "string" {
207 self.skip_balanced(close)?;
208 push_bib_warning(&mut self.warnings, "BibTeX @string macros are not expanded");
209 return Ok(());
210 }
211
212 self.skip_whitespace();
213 let key_start = self.index;
214 while let Some(character) = self.peek_char() {
215 if character == ',' || character == close {
216 break;
217 }
218 self.bump_char();
219 if self.index - key_start > MAX_BIB_KEY_BYTES {
220 return Err(Error::LimitExceeded(format!(
221 "BibTeX entry key exceeds {MAX_BIB_KEY_BYTES} bytes"
222 )));
223 }
224 }
225 let key = self.text[key_start..self.index].trim().to_owned();
226 if self.bump_if(',') {
227 } else if self.peek_char() == Some(close) {
229 self.bump_char();
230 self.emit_record(&kind, &key, &[])?;
231 return Ok(());
232 } else {
233 return Err(Error::InvalidInput(format!(
234 "BibTeX entry @{kind} is missing its key separator"
235 )));
236 }
237
238 let mut fields = Vec::<(String, String)>::new();
239 let mut entry_bytes = key.len();
240 let mut saw_macro = false;
241 loop {
242 self.skip_whitespace();
243 while self.bump_if(',') {
244 self.skip_whitespace();
245 }
246 if self.peek_char() == Some(close) {
247 self.bump_char();
248 break;
249 }
250 if self.peek_char().is_none() {
251 return Err(Error::InvalidInput(format!(
252 "BibTeX entry @{kind} has no closing delimiter"
253 )));
254 }
255 let field_start = self.index;
256 while self.peek_char().is_some_and(|character| {
257 character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
258 }) {
259 self.bump_char();
260 if self.index - field_start > MAX_BIB_FIELD_NAME_BYTES {
261 return Err(Error::LimitExceeded(format!(
262 "BibTeX field name exceeds {MAX_BIB_FIELD_NAME_BYTES} bytes"
263 )));
264 }
265 }
266 if field_start == self.index {
267 return Err(Error::InvalidInput(format!(
268 "BibTeX entry @{kind} contains an invalid field name"
269 )));
270 }
271 let field_name = self.text[field_start..self.index].to_ascii_lowercase();
272 self.skip_whitespace();
273 if !self.bump_if('=') {
274 return Err(Error::InvalidInput(format!(
275 "BibTeX field {field_name} has no equals sign"
276 )));
277 }
278 let (value, uses_macro) = self.parse_value(close)?;
279 entry_bytes = entry_bytes
280 .saturating_add(field_name.len())
281 .saturating_add(value.len());
282 if entry_bytes > MAX_BIB_ENTRY_BYTES {
283 return Err(Error::LimitExceeded(format!(
284 "BibTeX entry exceeds {MAX_BIB_ENTRY_BYTES} decoded bytes"
285 )));
286 }
287 saw_macro |= uses_macro;
288 if fields.len() >= MAX_BIB_FIELDS_PER_ENTRY {
289 return Err(Error::LimitExceeded(format!(
290 "BibTeX entry @{kind} exceeds {MAX_BIB_FIELDS_PER_ENTRY} fields"
291 )));
292 }
293 fields.push((field_name, clean_bib_value(&value)));
294 }
295 if saw_macro {
296 push_bib_warning(
297 &mut self.warnings,
298 "BibTeX string macros or concatenations were kept literal",
299 );
300 }
301 self.emit_record(&kind, &key, &fields)
302 }
303
304 fn parse_value(&mut self, close: char) -> Result<(String, bool)> {
305 let mut output = String::new();
306 let mut brace_depth = 0usize;
307 let mut in_quotes = false;
308 let mut escaped = false;
309 let mut uses_macro = false;
310 let mut token = String::new();
311 loop {
312 let Some(character) = self.peek_char() else {
313 return Err(Error::InvalidInput(
314 "BibTeX field value is not terminated".into(),
315 ));
316 };
317 if in_quotes {
318 self.bump_char();
319 if escaped {
320 output.push('\\');
321 output.push(character);
322 escaped = false;
323 } else if character == '\\' {
324 escaped = true;
325 } else if character == '"' {
326 in_quotes = false;
327 } else {
328 output.push(character);
329 }
330 if output.len() > MAX_BIB_FIELD_VALUE_BYTES {
331 return Err(Error::LimitExceeded(format!(
332 "BibTeX field value exceeds {MAX_BIB_FIELD_VALUE_BYTES} bytes"
333 )));
334 }
335 continue;
336 }
337 if character == '\\' {
338 self.bump_char();
339 output.push('\\');
340 if let Some(escaped) = self.bump_char() {
341 output.push(escaped);
342 }
343 if output.len() > MAX_BIB_FIELD_VALUE_BYTES {
344 return Err(Error::LimitExceeded(format!(
345 "BibTeX field value exceeds {MAX_BIB_FIELD_VALUE_BYTES} bytes"
346 )));
347 }
348 token.clear();
349 continue;
350 }
351 if brace_depth == 0 && (character == ',' || character == close) {
352 if looks_like_bib_macro(&token) {
353 uses_macro = true;
354 }
355 break;
356 }
357 self.bump_char();
358 match character {
359 '"' if brace_depth == 0 => {
360 if looks_like_bib_macro(&token) {
361 uses_macro = true;
362 }
363 token.clear();
364 in_quotes = true;
365 }
366 '"' => output.push('"'),
367 '{' => {
368 if brace_depth >= MAX_BIB_NESTING {
369 return Err(Error::LimitExceeded(format!(
370 "BibTeX value exceeds nesting depth {MAX_BIB_NESTING}"
371 )));
372 }
373 brace_depth += 1;
374 output.push(character);
375 token.clear();
376 }
377 '}' if brace_depth > 0 => {
378 brace_depth -= 1;
379 output.push(character);
380 token.clear();
381 }
382 '#' if brace_depth == 0 => {
383 if looks_like_bib_macro(&token) {
384 uses_macro = true;
385 }
386 token.clear();
387 output.push(' ');
388 }
389 character => {
390 if character.is_ascii_alphanumeric() || character == '_' {
391 token.push(character);
392 } else if !character.is_whitespace() {
393 if looks_like_bib_macro(&token) {
394 uses_macro = true;
395 }
396 token.clear();
397 }
398 output.push(character);
399 }
400 }
401 if output.len() > MAX_BIB_FIELD_VALUE_BYTES {
402 return Err(Error::LimitExceeded(format!(
403 "BibTeX field value exceeds {MAX_BIB_FIELD_VALUE_BYTES} bytes"
404 )));
405 }
406 }
407 if brace_depth != 0 || in_quotes || escaped {
408 return Err(Error::InvalidInput(
409 "BibTeX field contains an unbalanced value".into(),
410 ));
411 }
412 if looks_like_bib_macro(&token) {
413 uses_macro = true;
414 }
415 Ok((output, uses_macro))
416 }
417
418 fn emit_record(&mut self, kind: &str, key: &str, fields: &[(String, String)]) -> Result<()> {
419 self.entry_count = self.entry_count.saturating_add(1);
420 if self.entry_count > MAX_BIB_ENTRIES {
421 return Err(Error::LimitExceeded(format!(
422 "BibTeX input exceeds {MAX_BIB_ENTRIES} entries"
423 )));
424 }
425 let mut text = format!("@{kind}{{{key}}}");
426 for (field, value) in fields {
427 text.push('\n');
428 text.push_str(field);
429 text.push_str(": ");
430 text.push_str(value);
431 }
432 self.rendered_bytes = self.rendered_bytes.saturating_add(text.len());
433 if self.rendered_bytes > MAX_BIB_RENDERED_TEXT_BYTES {
434 return Err(Error::LimitExceeded(format!(
435 "BibTeX rendered text exceeds {MAX_BIB_RENDERED_TEXT_BYTES} bytes"
436 )));
437 }
438 self.blocks.push(HtmlBlock::Paragraph { text });
439 Ok(())
440 }
441
442 fn skip_balanced(&mut self, close: char) -> Result<()> {
443 let mut depth = 1usize;
444 let mut in_quotes = false;
445 let mut escaped = false;
446 while let Some(character) = self.bump_char() {
447 if in_quotes {
448 if escaped {
449 escaped = false;
450 } else if character == '\\' {
451 escaped = true;
452 } else if character == '"' {
453 in_quotes = false;
454 }
455 continue;
456 }
457 match character {
458 '"' => in_quotes = true,
459 '{' => depth = depth.saturating_add(1),
460 '}' if close == '}' && depth == 1 => return Ok(()),
461 '}' if close == '}' => depth -= 1,
462 '(' if close == ')' => depth = depth.saturating_add(1),
463 ')' if close == ')' && depth == 1 => return Ok(()),
464 ')' if close == ')' => depth -= 1,
465 _ => {}
466 }
467 if depth > MAX_BIB_NESTING {
468 return Err(Error::LimitExceeded(format!(
469 "BibTeX skipped record exceeds nesting depth {MAX_BIB_NESTING}"
470 )));
471 }
472 }
473 Err(Error::InvalidInput(
474 "BibTeX ignored record is not terminated".into(),
475 ))
476 }
477
478 fn skip_space_and_comments(&mut self) -> bool {
479 loop {
480 while self.peek_char().is_some_and(char::is_whitespace) {
481 self.bump_char();
482 }
483 if self.peek_char() == Some('%') {
484 while let Some(character) = self.bump_char() {
485 if character == '\n' {
486 break;
487 }
488 }
489 continue;
490 }
491 return self.peek_char().is_some();
492 }
493 }
494
495 fn skip_whitespace(&mut self) {
496 while self.peek_char().is_some_and(char::is_whitespace) {
497 self.bump_char();
498 }
499 }
500
501 fn peek_char(&self) -> Option<char> {
502 self.text.get(self.index..)?.chars().next()
503 }
504
505 fn bump_char(&mut self) -> Option<char> {
506 let character = self.peek_char()?;
507 self.index += character.len_utf8();
508 Some(character)
509 }
510
511 fn bump_if(&mut self, expected: char) -> bool {
512 if self.peek_char() == Some(expected) {
513 self.bump_char();
514 true
515 } else {
516 false
517 }
518 }
519}
520
521fn looks_like_bib_macro(token: &str) -> bool {
522 !token.is_empty() && !token.bytes().all(|byte| byte.is_ascii_digit())
523}
524
525fn clean_bib_value(value: &str) -> String {
526 let mut output = String::with_capacity(value.len());
527 let mut characters = value.chars().peekable();
528 while let Some(character) = characters.next() {
529 match character {
530 '{' | '}' => {}
531 '\\' => {
532 if let Some(next) = characters.next() {
533 if next.is_ascii_alphabetic() {
534 output.push(next);
535 while characters
536 .peek()
537 .is_some_and(|character| character.is_ascii_alphabetic())
538 {
539 output.push(characters.next().unwrap_or_default());
540 }
541 } else if matches!(next, '&' | '%' | '_' | '#' | '$' | '{' | '}' | '~') {
542 output.push(if next == '~' { ' ' } else { next });
543 } else {
544 output.push(next);
545 }
546 }
547 }
548 character if character.is_control() && !matches!(character, '\n' | '\t') => {
549 output.push('\u{fffd}')
550 }
551 character => output.push(character),
552 }
553 }
554 output.split_whitespace().collect::<Vec<_>>().join(" ")
555}
556
557fn push_bib_warning(warnings: &mut Vec<String>, warning: &str) {
558 if !warnings.iter().any(|existing| existing == warning) {
559 warnings.push(warning.to_owned());
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn parses_nested_braces_quotes_and_macro_literals() {
569 let input = "% bibliography\n@Article{key, author = {A. Name}, title = {{A {Nested} Title}}, year = 2024, note = \"A, quoted value\", comment = {An unpaired \" quote}, journal = jname # \" Review\"}\n@string{jname = {Journal}}\n";
570 let (blocks, warnings) = parse_bibtex_blocks_with_warnings(input).unwrap();
571 let output = format!("{blocks:?}");
572 assert!(output.contains("@article{key}"));
573 assert!(output.contains("A Nested Title"));
574 assert!(output.contains("A, quoted value"));
575 assert!(output.contains(r#"An unpaired \" quote"#));
576 assert!(output.contains("jname Review"));
577 assert!(
578 warnings
579 .iter()
580 .any(|warning| warning.contains("@string macros"))
581 );
582 assert!(
583 warnings
584 .iter()
585 .any(|warning| warning.contains("macros or concatenations"))
586 );
587 }
588
589 #[test]
590 fn enforces_bibtex_nesting_and_text_budgets() {
591 let text = format!(
592 "@article{{k,title={{{}}}}}",
593 "{".repeat(MAX_BIB_NESTING + 1)
594 );
595 assert!(parse_bibtex_blocks(&text).is_err());
596
597 let field_value = "a".repeat(900 * 1024);
598 let oversized_field =
599 format!("@article{{k,title={{{field_value}\n{field_value}\n{field_value}}}}}\n");
600 assert!(parse_bibtex_blocks(&oversized_field).is_err());
601 }
602}