Skip to content

Commit 49017bc

Browse files
committed
gh-159672: Bound keyword replay source and total work
1 parent 7cfe83f commit 49017bc

4 files changed

Lines changed: 45 additions & 6 deletions

File tree

‎Lib/test/test_traceback.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,33 @@ def test_keyword_trials_do_not_call_normalizer(self):
19481948
normalize.assert_not_called()
19491949
self.assertIsNone(caught.exception._metadata)
19501950

1951+
@cpython_only
1952+
def test_keyword_replay_source_limit(self):
1953+
with tempfile.TemporaryDirectory() as script_dir:
1954+
for size in (64 * 1024 - 512, 64 * 1024 + 512):
1955+
literal = "value = " + repr("a" * size) + "\n"
1956+
for source in (literal + "impor math", "impor math\n" + literal):
1957+
with self.subTest(size=size, typo_first=source.startswith("impor")):
1958+
with self.assertRaises(SyntaxError) as caught:
1959+
compile(source, "<test>", "exec")
1960+
expected = size < 64 * 1024
1961+
self.assertEqual(caught.exception._metadata is not None, expected)
1962+
script = make_script(script_dir, "large_keyword_typo", source)
1963+
_, _, stderr = assert_python_failure(script)
1964+
self.assertEqual(b"Did you mean 'import'" in stderr, expected)
1965+
# The size limit applies only to optional hints.
1966+
compile("value = " + repr("a" * (1024 * 1024)), "<test>", "exec")
1967+
1968+
@cpython_only
1969+
def test_keyword_replay_work_limit(self):
1970+
# Several candidates must share the budget, including failed passes.
1971+
for count, expected in ((100, "in"), (300, None)):
1972+
source = "x = 1\n" * count + "for x im seq:\n pass"
1973+
with self.subTest(count=count), self.assertRaises(SyntaxError) as caught:
1974+
compile(source, "<test>", "exec")
1975+
metadata = caught.exception._metadata
1976+
self.assertEqual(metadata[0] if metadata else None, expected)
1977+
19511978
@requires_debug_ranges()
19521979
@force_not_colorized_test_class
19531980
class PurePythonTracebackErrorCaretTests(

‎Parser/pegen.c‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@ add_keyword_typo(KeywordTypo *candidates, int *count, int mark,
11221122
}
11231123

11241124
static int
1125-
try_keyword_typo(Parser *p, KeywordTypo *candidate)
1125+
try_keyword_typo(Parser *p, KeywordTypo *candidate, KeywordTrial *state)
11261126
{
11271127
// Token text belongs to the original arena. Token headers and memo entries
11281128
// must be private: even a failed trial can memoize nodes containing its edit.
@@ -1159,7 +1159,8 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate)
11591159
p, candidate->keyword, strlen(candidate->keyword));
11601160
edited->bytes = replacement;
11611161

1162-
KeywordTrial state = {.budget = MAX_KEYWORD_TYPO_STEPS};
1162+
state->furthest = 0;
1163+
state->incomplete_suite = 0;
11631164
Parser trial = *p;
11641165
trial.tokens = index;
11651166
trial.arena = arena;
@@ -1168,7 +1169,7 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate)
11681169
trial.level = 0;
11691170
trial.error_indicator = 0;
11701171
trial.call_invalid_rules = 0;
1171-
trial.keyword_trial = &state;
1172+
trial.keyword_trial = state;
11721173
// Force trials through the existing stack-check slow path to count rule
11731174
// entries without adding a branch to normal parsing. The slow path still
11741175
// checks the thread's real stack limit.
@@ -1192,7 +1193,7 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate)
11921193
if (!accepted && !trial.error_indicator && candidate->is_alias &&
11931194
p->feature_version == PY_MINOR_VERSION)
11941195
{
1195-
for (int i = candidate->mark; i < state.furthest; i++) {
1196+
for (int i = candidate->mark; i < state->furthest; i++) {
11961197
if (tokens[i].type == NEWLINE) {
11971198
accepted = 1;
11981199
break;
@@ -1204,7 +1205,7 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate)
12041205
_PyPegen_parse(&trial);
12051206
// Version checks on an enclosing rule can run after its suite. For
12061207
// older grammars, require a complete repair so those checks execute.
1207-
accepted = state.incomplete_suite && p->feature_version == PY_MINOR_VERSION;
1208+
accepted = state->incomplete_suite && p->feature_version == PY_MINOR_VERSION;
12081209
}
12091210
// No Parser_Free: the trial borrows the tokenizer and type-ignore comments,
12101211
// and its token headers are a single allocation.
@@ -1224,6 +1225,7 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
12241225
if ((p->start_rule != Py_file_input && p->start_rule != Py_eval_input &&
12251226
p->start_rule != Py_single_input) ||
12261227
p->fill == 0 || p->fill > MAX_KEYWORD_TYPO_TOKENS ||
1228+
_PyTokenizer_GetInfo(p->tok).input_span.end > MAX_KEYWORD_TYPO_BYTES ||
12271229
p->tokens[p->fill - 1]->type != ENDMARKER ||
12281230
_PyTokenizer_IsInteractive(p->tok) || !PyUnicode_Check(error->msg))
12291231
{
@@ -1301,8 +1303,10 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
13011303
}
13021304
}
13031305
qsort(candidates, count, sizeof(*candidates), compare_keyword_typos);
1306+
// All candidates and both parser passes share this work budget.
1307+
KeywordTrial state = {.budget = MAX_KEYWORD_TYPO_STEPS};
13041308
for (int i = 0; i < count; i++) {
1305-
int accepted = try_keyword_typo(p, &candidates[i]);
1309+
int accepted = try_keyword_typo(p, &candidates[i], &state);
13061310
if (accepted < 0) {
13071311
return NULL;
13081312
}

‎Parser/pegen.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
// Error-only token replay is bounded independently of the source length.
3333
#define MAX_KEYWORD_TYPO_TOKENS 4096
34+
#define MAX_KEYWORD_TYPO_BYTES (64 * 1024)
3435

3536
typedef struct _memo {
3637
int type;

‎Parser/pegen_errors.c‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,13 @@ _PyPegen_tokenize_full_source_to_check_for_errors(Parser *p) {
160160

161161
for (;;) {
162162
_PyTokenizer_Get(p->tok, &new_token);
163+
if (cache_tokens &&
164+
_PyTokenizer_GetInfo(p->tok).input_span.end > MAX_KEYWORD_TYPO_BYTES)
165+
{
166+
// Logical offsets include discarded input in file mode. Stop
167+
// before copying a large token into the parser's arena.
168+
cache_tokens = 0;
169+
}
163170
// Retain the tokens this error check already reads for bounded typo
164171
// trials. Never treat a truncated cache as complete input.
165172
if (cache_tokens && p->fill < MAX_KEYWORD_TYPO_TOKENS &&

0 commit comments

Comments
 (0)