Coverage for src/local_deep_research/security/toml_limits.py: 100%
33 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Limits checked before tomllib parses TOML that a user supplied.
3tomllib is pure Python. It rebuilds a key's tuple for every part and, on a
4dotted key/value line, also builds and keeps every prefix of the key, so
5one long key costs time (and, on a key/value line, memory) that grows with
6the square of its length. Uploaded TOML documents and the raw configuration
7endpoint both parse through ``loads_toml_within_limits``.
8"""
10import re
11import tomllib
12from itertools import islice
13from typing import Any
15# Largest TOML document parsed, in UTF-8 bytes. TOML files are mostly
16# configuration: a full pyproject.toml is about 30 KB, and gitleaks' rule
17# set, one of the larger common ones, about 100 KB, so this leaves fivefold
18# headroom. tomllib is pure Python, and hostile but valid documents were
19# measured holding several hundred bytes of parser memory per input byte;
20# neither the general upload limit (3 GB by default) nor the default 16 MB
21# JSON body limit bounds that.
22MAX_TOML_BYTES = 512 * 1024
24# Most dotted parts allowed in one key or table header ("a.b.c" has three).
25# The deepest key in 252 sampled TOML files had five parts. Under this cap
26# the total work stays proportional to the document size. A number such as
27# 1.5 also scans as a two-part run, so keep this at least 2.
28MAX_TOML_KEY_PARTS = 32
30# One key part: bare, or a one-line basic or literal string that ends
31# where tomllib ends it.
32_KEY_PART = (
33 r"(?:[A-Za-z0-9_-]++"
34 r"|\x22(?:[^\x22\\\n]++|\\[^\n])*+\x22"
35 r"|\x27[^\x27\n]*+\x27)"
36)
37_KEY_PART_RE = re.compile(_KEY_PART)
39# Some alternative matches at every position, so the tokens cover the text
40# in order and, up to where tomllib would reject the document, string and
41# comment contents are skipped exactly where tomllib skips them. A "key"
42# token is a run of dot-separated key parts.
43_TOKEN_RE = re.compile(
44 rf"""
45 (?P<string>
46 \x22{{3}} (?: [^\x22\\]++ | \\[\s\S] | \x22(?!\x22{{2}}) )*+ \x22{{3,5}}
47 | \x27{{3}} (?: [^\x27]++ | \x27(?!\x27{{2}}) )*+ \x27{{3,5}}
48 )
49 | (?P<key>
50 (?!\x22{{3}}|\x27{{3}})
51 {_KEY_PART} (?: [ \t]*+ \. [ \t]*+ {_KEY_PART} )*+
52 )
53 | (?P<comment> \# [^\n]*+ )
54 | (?P<other> [^A-Za-z0-9_\-\x22\x27\#]++ )
55 | (?P<unterminated> [\x22\x27] )
56 """,
57 re.VERBOSE,
58)
61class TOMLLimitError(ValueError):
62 """TOML input exceeds a limit that is checked before tomllib runs."""
65def _has_overlong_key(text: str, max_parts: int) -> bool:
66 """Return whether a key or table header has more than *max_parts* parts.
68 The scan stops at an unterminated string, where tomllib rejects the
69 document, so it stays linear in the length of *text*.
70 """
71 for token in _TOKEN_RE.finditer(text):
72 kind = token.lastgroup
73 if kind == "unterminated":
74 return False
75 start, end = token.span()
76 if kind == "key" and text.find(".", start, end) != -1:
77 parts = _KEY_PART_RE.finditer(text, start, end)
78 if sum(1 for _ in islice(parts, max_parts + 1)) > max_parts:
79 return True
80 return False
83def check_toml_size(size: int) -> None:
84 """Raise TOMLLimitError when a *size*-byte document is over the limit."""
85 if size > MAX_TOML_BYTES:
86 raise TOMLLimitError(
87 f"TOML document is larger than {MAX_TOML_BYTES} bytes"
88 )
91def loads_toml_within_limits(text: str) -> dict[str, Any]:
92 """Parse *text* with tomllib unless it exceeds the limits.
94 Raises TOMLLimitError, before tomllib runs, when the UTF-8 form of
95 *text* is larger than MAX_TOML_BYTES or a key or table header has more
96 than MAX_TOML_KEY_PARTS dotted parts, and TypeError when *text* is not
97 a string. Otherwise it returns or raises what
98 tomllib.loads does, such as TOMLDecodeError for invalid TOML.
99 """
100 if not isinstance(text, str):
101 raise TypeError(f"Expected str object, not '{type(text).__qualname__}'")
102 # A character takes at least one UTF-8 byte, so the length check refuses
103 # most oversized text before its encoded size is measured.
104 check_toml_size(len(text))
105 check_toml_size(len(text.encode("utf-8", "surrogatepass")))
106 # tomllib.loads folds CRLF before it parses; scan that same text.
107 text = text.replace("\r\n", "\n")
108 if _has_overlong_key(text, MAX_TOML_KEY_PARTS):
109 raise TOMLLimitError(
110 "TOML key or table header has more than "
111 f"{MAX_TOML_KEY_PARTS} dotted parts"
112 )
113 return tomllib.loads(text)