Skip to content

Commit 9567f1a

Browse files
authored
Merge pull request #22443 from github/redsun82-python-tsg-variation-selector-crash
Python: fix files being silently dropped over Rust/Python escape mismatch
2 parents a035968 + caac5d8 commit 9567f1a

11 files changed

Lines changed: 317 additions & 7 deletions

File tree

python/extractor/semmle/logging.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ def write_message_with_proc(level, proc_id, text):
6565

6666
_logging_process = None
6767

68+
def format_message(fmt, args):
69+
'''Applies `%`-formatting to `fmt`, but only when there are arguments to interpolate.
70+
71+
This mirrors the standard library's `logging` behaviour, and means that a message that has
72+
already been formatted -- and may therefore contain arbitrary `%` directives coming from the
73+
code being analysed -- is passed through unharmed.'''
74+
return fmt % args if args else fmt
75+
6876
def stop():
6977
_logging_process.join()
7078

@@ -105,7 +113,7 @@ def log(self, level, fmt, *args):
105113
'''Log a message in a process safe fashion.
106114
Message will be of the form [level] fmt%args.'''
107115
if level <= self.level:
108-
txt = fmt % args
116+
txt = format_message(fmt, args)
109117
try:
110118
self.queue.put((self.color | level, self.proc_id, txt), False)
111119
except Exception:

python/extractor/semmle/python/imports.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,6 @@ def importer_from_options(options, finder, logger):
252252
importer = CachingModuleImporter(options.trap_cache, finder, logger)
253253
except Exception as ex:
254254
if options.trap_cache is not None:
255-
logger.warn("Failed to create caching importer: %s", ex)
255+
logger.warning("Failed to create caching importer: %s", ex)
256256
importer = ModuleImporter(finder, logger)
257257
return importer

python/extractor/semmle/python/parser/dump_ast.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,12 @@ def visit(self, node, level=0, visited=None):
9999
class StdoutLogger(logging.Logger):
100100
error_count = 0
101101
def log(self, level, fmt, *args):
102-
sys.stdout.write(fmt % args + "\n")
102+
sys.stdout.write(logging.format_message(fmt, args) + "\n")
103103

104104
def info(self, fmt, *args):
105105
self.log(logging.INFO, fmt, *args)
106106

107-
def warn(self, fmt, *args):
107+
def warning(self, fmt, *args):
108108
self.log(logging.WARN, fmt, *args)
109109
self.error_count += 1
110110

python/extractor/semmle/python/parser/tsg_parser.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Functions and classes used for parsing Python files using `tree-sitter-graph`
44

55
from ast import literal_eval
6+
import re
67
import sys
78
import os
89
import semmle.python.parser
@@ -144,6 +145,7 @@ def read_tsg_python_output(path, logger):
144145
elif value == "#null": # e.g. `exc: #null`
145146
value = None
146147
else: # literal values, e.g. `name: "k1.k2"` or `level: 5`
148+
value = rust_to_python_escapes(value)
147149
try:
148150
if key =="s" and value[0] == '"': # e.g. `s: "k1.k2"`
149151
value = evaluate_string(value)
@@ -171,6 +173,36 @@ def read_tsg_python_output(path, logger):
171173
logger.debug("Read {} nodes and {} edges from TSG output".format(len(node_attr), len(edge_attr)))
172174
return node_attr, edge_attr
173175

176+
# `tsg-python` serialises string values using Rust's `Debug` formatting, which diverges from what
177+
# Python's `literal_eval` accepts in two ways:
178+
# - characters Rust considers non-printable -- including grapheme-extending ones such as the U+FE0F
179+
# variation selector, U+200D zero width joiner and combining accents -- are rendered as `\u{...}`,
180+
# a syntax Python does not know at all;
181+
# - NUL is rendered as `\0`, which Python reads as the start of an *octal* escape, silently
182+
# swallowing up to two more digits (NUL followed by `1` is emitted as `"\01"`, which decodes
183+
# to `\x01`).
184+
# Everything else Rust emits (`\t`, `\r`, `\n`, `\\`, `\"`, and unescaped characters) is read back
185+
# identically by `literal_eval`, as verified exhaustively over every Unicode scalar value.
186+
_RUST_ESCAPE = re.compile(r"\\(?:u\{([0-9a-fA-F]{1,6})\}|.)", re.DOTALL)
187+
188+
def rust_to_python_escapes(text):
189+
"""Rewrites Rust escapes in `text` that Python would reject or misread into their equivalents.
190+
191+
Matching every escape sequence (rather than only the offending ones) keeps the scan in step with
192+
the backslashes, so an escaped backslash -- how a literal `\\u{fe0f}` in the source is
193+
serialised -- is left alone."""
194+
if "\\u{" not in text and "\\0" not in text:
195+
return text
196+
def replace(match):
197+
code_point = match.group(1)
198+
if code_point is None:
199+
return "\\x00" if match.group(0) == "\\0" else match.group(0)
200+
code_point = int(code_point, 16)
201+
if code_point > 0xFFFF:
202+
return "\\U{:08x}".format(code_point)
203+
return "\\u{:04x}".format(code_point)
204+
return _RUST_ESCAPE.sub(replace, text)
205+
174206
def evaluate_string(s):
175207
s = literal_eval(s)
176208
prefix, quotes, content = split_string(s, None)

python/extractor/semmle/python/passes/flow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from semmle.python.passes import unroller
1313
from semmle.python import modules
1414
import semmle.graph as graph
15-
from semmle.logging import Logger
15+
from semmle.logging import Logger, format_message
1616

1717
__all__ = [ 'FlowPass' ]
1818

@@ -1924,7 +1924,7 @@ def write_ssa_phi(out, phi, arg):
19241924
class FakeLogger(object):
19251925

19261926
def debug(self, fmt, *args):
1927-
print(fmt % args)
1927+
print(format_message(fmt, args))
19281928

19291929
def traceback(self):
19301930
print(traceback.format_exc())

python/extractor/semmle/worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self, max_depth, logger: Logger):
3939
changed_paths = data.get('changes', [])
4040
self.overlay_changes = { os.path.abspath(p) for p in changed_paths }
4141
except (IOError, ValueError) as e:
42-
logger.warn("Failed to read overlay changes from '%s' (falling back to full extraction): %s", overlay_changes_file, e)
42+
logger.warning("Failed to read overlay changes from '%s' (falling back to full extraction): %s", overlay_changes_file, e)
4343
self.overlay_changes = None
4444

4545
def add_root(self, mod):
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
Module: [6, 0] - [29, 0]
2+
body: [
3+
TypeAlias: [6, 0] - [6, 12]
4+
name:
5+
Name: [6, 5] - [6, 6]
6+
variable: Variable('X', None)
7+
ctx: Store
8+
type_parameters: []
9+
value:
10+
Name: [6, 9] - [6, 12]
11+
variable: Variable('int', None)
12+
ctx: Load
13+
Assign: [9, 0] - [9, 37]
14+
targets: [
15+
Name: [9, 0] - [9, 4]
16+
variable: Variable('warn', None)
17+
ctx: Store
18+
]
19+
value:
20+
Str: [9, 7] - [9, 37]
21+
s: '⚠️ problem %s: %s'
22+
prefix: '"'
23+
implicitly_concatenated_parts: None
24+
Assign: [10, 0] - [10, 35]
25+
targets: [
26+
Name: [10, 0] - [10, 8]
27+
variable: Variable('warn_raw', None)
28+
ctx: Store
29+
]
30+
value:
31+
Str: [10, 11] - [10, 35]
32+
s: '⚠️ problem %s: %s'
33+
prefix: '"'
34+
implicitly_concatenated_parts: None
35+
Assign: [13, 0] - [13, 19]
36+
targets: [
37+
Name: [13, 0] - [13, 3]
38+
variable: Variable('zwj', None)
39+
ctx: Store
40+
]
41+
value:
42+
Str: [13, 6] - [13, 19]
43+
s: '👨\u200d💻'
44+
prefix: '"'
45+
implicitly_concatenated_parts: None
46+
Assign: [16, 0] - [16, 14]
47+
targets: [
48+
Name: [16, 0] - [16, 3]
49+
variable: Variable('nfd', None)
50+
ctx: Store
51+
]
52+
value:
53+
Str: [16, 6] - [16, 14]
54+
s: 'café'
55+
prefix: '"'
56+
implicitly_concatenated_parts: None
57+
Assign: [17, 0] - [17, 28]
58+
targets: [
59+
Name: [17, 0] - [17, 11]
60+
variable: Variable('soft_hyphen', None)
61+
ctx: Store
62+
]
63+
value:
64+
Str: [17, 14] - [17, 28]
65+
s: 'soft\xadhyphen'
66+
prefix: '"'
67+
implicitly_concatenated_parts: None
68+
Assign: [20, 0] - [20, 12]
69+
targets: [
70+
Name: [20, 0] - [20, 6]
71+
variable: Variable('café', None)
72+
ctx: Store
73+
]
74+
value:
75+
Name: [20, 9] - [20, 12]
76+
variable: Variable('nfd', None)
77+
ctx: Load
78+
Assign: [23, 0] - [23, 23]
79+
targets: [
80+
Name: [23, 0] - [23, 3]
81+
variable: Variable('raw', None)
82+
ctx: Store
83+
]
84+
value:
85+
Str: [23, 6] - [23, 23]
86+
s: '⚠️\\u{fe0f}'
87+
prefix: 'r"'
88+
implicitly_concatenated_parts: None
89+
Assign: [24, 0] - [24, 23]
90+
targets: [
91+
Name: [24, 0] - [24, 6]
92+
variable: Variable('joined', None)
93+
ctx: Store
94+
]
95+
value:
96+
JoinedStr: [24, 9] - [24, 23]
97+
values: [
98+
Str: [24, 9] - [24, 12]
99+
s: ''
100+
prefix: 'f"'
101+
implicitly_concatenated_parts: None
102+
Name: [24, 12] - [24, 15]
103+
variable: Variable('zwj', None)
104+
ctx: Load
105+
Str: [24, 15] - [24, 23]
106+
s: '⚠️'
107+
prefix: 'f"'
108+
implicitly_concatenated_parts: None
109+
]
110+
Assign: [25, 0] - [25, 37]
111+
targets: [
112+
Name: [25, 0] - [25, 12]
113+
variable: Variable('concatenated', None)
114+
ctx: Store
115+
]
116+
value:
117+
Str: [25, 15] - [25, 37]
118+
s: '⚠️👨\u200d💻'
119+
prefix: '"'
120+
implicitly_concatenated_parts: [
121+
StringPart: [25, 15] - [25, 23]
122+
prefix: '"'
123+
text: '"⚠️"'
124+
s: '⚠️'
125+
StringPart: [25, 24] - [25, 37]
126+
prefix: '"'
127+
text: '"👨\u200d💻"'
128+
s: '👨\u200d💻'
129+
]
130+
Assign: [28, 0] - [28, 17]
131+
targets: [
132+
Name: [28, 0] - [28, 1]
133+
variable: Variable('d', None)
134+
ctx: Store
135+
]
136+
value:
137+
Dict: [28, 4] - [28, 17]
138+
items: [
139+
KeyValuePair: [28, 5] - [28, 16]
140+
key:
141+
Str: [28, 5] - [28, 13]
142+
s: '⚠️'
143+
prefix: '"'
144+
implicitly_concatenated_parts: None
145+
value:
146+
Num: [28, 15] - [28, 16]
147+
n: 1
148+
text: '1'
149+
]
150+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Characters that Rust's `Debug` formatting escapes as `\u{...}` when `tsg-python` serialises the
2+
# source text. See https://github.com/github/codeql/issues/22435.
3+
4+
# PEP 695 syntax is what makes the old parser bail out and hand the file to `tsg-python` in the
5+
# first place, so keep the reported reproducer intact.
6+
type X = int
7+
8+
# U+FE0F variation selector, next to a `%` directive.
9+
warn = "\u26a0\ufe0f problem %s: %s"
10+
warn_raw = "⚠️ problem %s: %s"
11+
12+
# U+200D zero width joiner.
13+
zwj = "👨‍💻"
14+
15+
# Combining acute accent (NFD), and a soft hyphen.
16+
nfd = "café"
17+
soft_hyphen = "soft­hyphen"
18+
19+
# Combining marks are valid in identifiers too.
20+
café = nfd
21+
22+
# In f-strings, raw strings and implicit concatenations too.
23+
raw = r"⚠️\u{fe0f}"
24+
joined = f"{zwj}⚠️"
25+
concatenated = "⚠️" "👨‍💻"
26+
27+
# ... and outside of string literals.
28+
d = {"⚠️": 1} # comment with ⚠️ and 👨‍💻
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import unittest
2+
3+
from ast import literal_eval
4+
5+
from semmle.logging import format_message
6+
from semmle.python.parser.tsg_parser import evaluate_string, rust_to_python_escapes
7+
8+
9+
class RustEscapeTest(unittest.TestCase):
10+
"""`tsg-python` serialises strings with Rust's `Debug` formatting, which escapes characters such
11+
as U+FE0F as `\\u{...}` -- a syntax Python's `literal_eval` does not accept -- and NUL as `\\0`,
12+
which Python reads as an octal escape."""
13+
14+
def test_untouched_without_escapes(self):
15+
text = '"caf\u00e9 \u2713 \U0001f4be"'
16+
self.assertEqual(rust_to_python_escapes(text), text)
17+
18+
def test_basic_multilingual_plane(self):
19+
self.assertEqual(rust_to_python_escapes(r'"\u{fe0f}"'), r'"\ufe0f"')
20+
self.assertEqual(rust_to_python_escapes(r'"\u{200d}"'), r'"\u200d"')
21+
22+
def test_short_and_astral_code_points(self):
23+
self.assertEqual(rust_to_python_escapes(r'"\u{0}"'), r'"\u0000"')
24+
self.assertEqual(rust_to_python_escapes(r'"\u{1f4a9}"'), r'"\U0001f4a9"')
25+
26+
def test_other_escapes_are_preserved(self):
27+
self.assertEqual(rust_to_python_escapes(r'"a\nb\"c\u{ad}"'), r'"a\nb\"c\u00ad"')
28+
29+
def test_escaped_backslash_is_not_an_escape_introducer(self):
30+
# How a raw string `r"\u{fe0f}"` in the analysed source gets serialised: the `\u{fe0f}` is
31+
# literal text, not an escape, and must survive unchanged.
32+
self.assertEqual(rust_to_python_escapes(r'"\\u{fe0f}"'), r'"\\u{fe0f}"')
33+
34+
def test_nul_is_not_left_as_an_octal_escape(self):
35+
# Rust renders NUL as `\0`; Python would read that as the start of an octal escape and
36+
# swallow the digits that follow, decoding `"\01"` to U+0001 instead of NUL then `1`.
37+
self.assertEqual(rust_to_python_escapes(r'"\01"'), r'"\x001"')
38+
39+
def test_every_escape_shape_round_trips(self):
40+
# Rust's `Debug for str` only ever emits these escape shapes. Check that each round-trips
41+
# with every printable ASCII neighbour before and after it.
42+
for escape_shape, expected in [
43+
(r'\0', "\x00"),
44+
(r'\t', "\t"),
45+
(r'\n', "\n"),
46+
(r'\r', "\r"),
47+
(r'\\', "\\"),
48+
(r'\"', '"'),
49+
(r'\u{1}', "\u0001"),
50+
(r'\u{1f}', "\u001f"),
51+
(r'\u{300}', "\u0300"),
52+
(r'\u{fe0f}', "\ufe0f"),
53+
(r'\u{e0100}', "\U000e0100"),
54+
(r'\u{10fffe}', "\U0010fffe"),
55+
]:
56+
for neighbour in map(chr, range(0x20, 0x7F)):
57+
rendered_neighbour = {"\\": r"\\", '"': r'\"'}.get(neighbour, neighbour)
58+
for position, text, expected_value in [
59+
("before", '"' + rendered_neighbour + escape_shape + '"', neighbour + expected),
60+
("after", '"' + escape_shape + rendered_neighbour + '"', expected + neighbour),
61+
]:
62+
with self.subTest(
63+
escape_shape=escape_shape,
64+
neighbour=neighbour,
65+
position=position,
66+
):
67+
self.assertEqual(literal_eval(rust_to_python_escapes(text)), expected_value)
68+
69+
def test_evaluate_string_on_reported_value(self):
70+
# The exact value from https://github.com/github/codeql/issues/22435 that used to raise
71+
# `truncated \uXXXX escape`.
72+
value = rust_to_python_escapes('"\\"\u26a0\\u{fe0f} problem %s: %s\\""')
73+
self.assertEqual(evaluate_string(value), "\u26a0\ufe0f problem %s: %s")
74+
75+
76+
class FormatMessageTest(unittest.TestCase):
77+
"""A pre-formatted log message may contain `%` directives coming from the analysed source, and
78+
must not be `%`-formatted again."""
79+
80+
def test_no_arguments(self):
81+
message = "Error while parsing value '%s: %s'"
82+
self.assertEqual(format_message(message, ()), message)
83+
84+
def test_with_arguments(self):
85+
self.assertEqual(format_message("%s and %s", ("a", "b")), "a and b")

0 commit comments

Comments
 (0)