Skip to main content

document_svg/document/
bib.rs

1//! Bounded BibTeX `.bib` bibliography preview.
2//!
3//! Parses entries and nested/quoted field values as text. BibTeX macros,
4//! preambles, citation styles, crossrefs, and TeX commands are never executed.
5
6use 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(); // @
163        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            // Key separator consumed.
228        } 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}