From df2b89a4a67ea983ecceb1b25d685d0e4bebd1f9 Mon Sep 17 00:00:00 2001 From: netliomax25-code Date: Mon, 22 Jun 2026 20:32:09 +0530 Subject: [PATCH 1/2] escape or reject a bare carriage return in multiline strings --- tests/test_api.py | 7 +++++++ tomlkit/items.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index b49c90ac..9defd60e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -511,6 +511,10 @@ def test_create_super_table_with_aot() -> None: ({"multiline": True}, 'My""String', '"""My""String"""'), ({"multiline": True}, 'My"""String', '"""My""\\"String"""'), ({"multiline": True}, 'My""""String', '"""My""\\""String"""'), + # a lone CR is a control char and is escaped, but a CRLF line ending is + # left intact + ({"multiline": True}, "My\rString", '"""My\\rString"""'), + ({"multiline": True}, "My\r\nString", '"""My\r\nString"""'), ( {"multiline": True}, '"""My"""Str"""ing"""', @@ -548,6 +552,9 @@ def test_create_string(kwargs: dict[str, Any], example: str, expected: str) -> N ({"literal": True}, "My\x0cString"), ({"literal": True}, "My\x7fString"), ({"multiline": True, "literal": True}, "My'''String"), + # a lone CR cannot be represented in a multiline literal string + ({"multiline": True, "literal": True}, "My\rString"), + ({"multiline": True, "literal": True}, "My\r\nMy\rString"), ], ) def test_create_string_with_invalid_characters( diff --git a/tomlkit/items.py b/tomlkit/items.py index 6cbb29a9..8b4d6c6c 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -232,6 +232,12 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I raise ConvertError(f"Unable to convert an object of {type(value)} to a TOML item") +# A carriage return that is not the CR of a CRLF line ending. Inside a multiline +# string only a raw line feed or a CRLF pair is a valid line ending; a lone CR is +# a control character. Literal strings cannot escape it, basic strings must. +_BARE_CR = re.compile(r"\r(?!\n)") + + class StringType(Enum): # Single Line Basic SLB = '"' @@ -2239,9 +2245,15 @@ def from_raw( if any(c in value for c in invalid): raise InvalidStringError(value, invalid, type_.value) + if type_ is StringType.MLL and _BARE_CR.search(value): + raise InvalidStringError(value, {"\r"}, type_.value) + escaped = type_.escaped_sequences string_value = escape_string(value, escaped) if escape and escaped else value + if type_ is StringType.MLB and escape: + string_value = _BARE_CR.sub(r"\\r", string_value) + return cls(type_, decode(value), string_value, Trivia()) From 77e61fbb860e559f90cc353b91b6db966cc6f30b Mon Sep 17 00:00:00 2001 From: Kartik Kenchi Date: Tue, 14 Jul 2026 14:40:33 +0530 Subject: [PATCH 2/2] fold the lone CR rejection into the invalid_sequences check --- tomlkit/items.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tomlkit/items.py b/tomlkit/items.py index 8b4d6c6c..07143a43 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -234,7 +234,7 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I # A carriage return that is not the CR of a CRLF line ending. Inside a multiline # string only a raw line feed or a CRLF pair is a valid line ending; a lone CR is -# a control character. Literal strings cannot escape it, basic strings must. +# a control character that a basic string must escape. _BARE_CR = re.compile(r"\r(?!\n)") @@ -273,12 +273,13 @@ def escaped_sequences(self) -> Collection[str]: def invalid_sequences(self) -> Collection[str]: # https://toml.io/en/v1.0.0#string forbidden_in_literal = CONTROL_CHARS - {"\t"} - allowed_in_multiline = {"\n", "\r"} + # CR stays forbidden in multiline: the value is checked with CRLF + # pairs collapsed, so any remaining CR is a lone control character return { StringType.SLB: (), StringType.MLB: (), StringType.SLL: forbidden_in_literal | {"'"}, - StringType.MLL: (forbidden_in_literal | {"'''"}) - allowed_in_multiline, + StringType.MLL: (forbidden_in_literal | {"'''"}) - {"\n"}, }[self] @property @@ -2242,12 +2243,11 @@ def from_raw( value = decode(value) invalid = type_.invalid_sequences - if any(c in value for c in invalid): + # collapse CRLF line endings so only a lone CR trips the CR check + checked = value.replace("\r\n", "\n") + if any(c in checked for c in invalid): raise InvalidStringError(value, invalid, type_.value) - if type_ is StringType.MLL and _BARE_CR.search(value): - raise InvalidStringError(value, {"\r"}, type_.value) - escaped = type_.escaped_sequences string_value = escape_string(value, escaped) if escape and escaped else value