Skip to main content

docling_core/
limits.rs

1//! Table cell budgets (#861; docling-core 2.103.1's `settings.max_table_cells`
2//! and `max_total_table_cells`, its answer to a DoS advisory).
3//!
4//! Every reader of a table materializes its grid — the dense `rows` the
5//! serializers print, the `num_rows × num_cols` slot map of the JSON export —
6//! so a table's cost is its declared area, not its bytes: a docling-JSON
7//! `TableData` can declare `num_rows = num_cols = 10^6` around one cell, and
8//! an OTSL stream (DocTags, DocLang) of one row of 10^6 tokens and 10^6
9//! rows of one token asks for 10^12 slots from a few megabytes. docling-core
10//! rejects both at load; so do the readers here, through one
11//! [`TableCellBudget`] per document, with the typed [`LimitExceeded`] on the
12//! error's source chain (read with `ConversionError::limit()`), never a bare
13//! parse error.
14//!
15//! The rows and columns are bounded on their own too, as upstream does: a
16//! table of many rows and no columns still allocates one (empty) row each.
17
18use std::fmt;
19
20/// Cells (`rows × cols`, and rows or columns alone) one table may declare;
21/// docling-core's `max_table_cells`.
22pub const MAX_TABLE_CELLS_VAR: &str = "DOCLING_RS_MAX_TABLE_CELLS";
23/// Cells every table of one document may declare together; docling-core's
24/// `max_total_table_cells`. Bounds a document of many tables each just
25/// under the per-table cap.
26pub const MAX_TOTAL_TABLE_CELLS_VAR: &str = "DOCLING_RS_MAX_TOTAL_TABLE_CELLS";
27
28/// `DOCLING_RS_MAX_TABLE_CELLS` (1 000 000, upstream's default).
29pub fn max_table_cells() -> usize {
30    crate::env::parse::<usize>(MAX_TABLE_CELLS_VAR).unwrap_or(1_000_000)
31}
32
33/// `DOCLING_RS_MAX_TOTAL_TABLE_CELLS` (5 000 000, upstream's default).
34pub fn max_total_table_cells() -> usize {
35    crate::env::parse::<usize>(MAX_TOTAL_TABLE_CELLS_VAR).unwrap_or(5_000_000)
36}
37
38/// An input asked for more than a safety limit allows. `variable` names the
39/// environment variable that raises the limit; `what` the input part.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LimitExceeded {
42    pub variable: &'static str,
43    pub limit: usize,
44    pub actual: usize,
45    pub what: String,
46}
47
48impl fmt::Display for LimitExceeded {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(
51            f,
52            "{}: {} cells exceeds the limit of {} ({})",
53            self.what, self.actual, self.limit, self.variable
54        )
55    }
56}
57
58impl std::error::Error for LimitExceeded {}
59
60/// The cell budget of one document: each table is checked against the
61/// per-table cap and added to the running total, which is checked against
62/// the per-document cap. One budget per conversion, read from the
63/// environment when it is created.
64#[derive(Debug, Clone)]
65pub struct TableCellBudget {
66    per_table: usize,
67    total_cap: usize,
68    total: usize,
69}
70
71impl Default for TableCellBudget {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl TableCellBudget {
78    pub fn new() -> Self {
79        Self {
80            per_table: max_table_cells(),
81            total_cap: max_total_table_cells(),
82            total: 0,
83        }
84    }
85
86    /// Account for a table of `rows × cols` declared cells. `what` names it
87    /// in the error (`docling-json #/tables/3`, `doclang <table>`).
88    pub fn table(&mut self, what: &str, rows: usize, cols: usize) -> Result<(), LimitExceeded> {
89        let area = rows.saturating_mul(cols);
90        if rows > self.per_table || cols > self.per_table || area > self.per_table {
91            return Err(LimitExceeded {
92                variable: MAX_TABLE_CELLS_VAR,
93                limit: self.per_table,
94                actual: area.max(rows).max(cols),
95                what: format!("{what} of {rows}x{cols}"),
96            });
97        }
98        self.total = self.total.saturating_add(area);
99        if self.total > self.total_cap {
100            return Err(LimitExceeded {
101                variable: MAX_TOTAL_TABLE_CELLS_VAR,
102                limit: self.total_cap,
103                actual: self.total,
104                what: format!("{what}: the document's tables together"),
105            });
106        }
107        Ok(())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn per_table_and_total_caps() {
117        let mut b = TableCellBudget {
118            per_table: 100,
119            total_cap: 250,
120            total: 0,
121        };
122        assert!(b.table("t", 10, 10).is_ok());
123        let e = b.table("t", 10, 11).unwrap_err();
124        assert_eq!(e.variable, MAX_TABLE_CELLS_VAR);
125        assert_eq!(e.actual, 110);
126        // Rows or columns alone past the cap, even with no area.
127        assert_eq!(b.table("t", 101, 0).unwrap_err().actual, 101);
128        assert!(b.table("t", 0, 101).is_err());
129        assert!(b.table("t", 10, 10).is_ok());
130        let e = b.table("t", 10, 10).unwrap_err();
131        assert_eq!(e.variable, MAX_TOTAL_TABLE_CELLS_VAR);
132        assert_eq!(e.actual, 300);
133        // No overflow on absurd dimensions.
134        let mut b = TableCellBudget::new();
135        assert!(b.table("t", usize::MAX, usize::MAX).is_err());
136        assert!(b.table("t", usize::MAX, 0).is_err());
137    }
138}