Skip to content

Commit 5ed1427

Browse files
committed
fix: harden PostgreSQL inventory extraction
1 parent f9e5890 commit 5ed1427

2 files changed

Lines changed: 410 additions & 38 deletions

File tree

scripts/pg_compat/extract_statements.py

Lines changed: 197 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
#!/usr/bin/env python3
22

33
import argparse
4+
import copy
45
import json
6+
import os
7+
from pathlib import Path
58

69
if __package__:
710
from .common import atomic_write_text, read_jsonl, statement_id
811
else:
912
from common import atomic_write_text, read_jsonl, statement_id
1013

1114

15+
VALID_RESULTS = frozenset(
16+
(
17+
"DEEP_SUPPORTED",
18+
"CLASSIFIED_ONLY",
19+
"PARTIAL",
20+
"ERROR",
21+
"TRAILING_INPUT",
22+
"TYPE_MISMATCH",
23+
"ORACLE_REJECTED",
24+
)
25+
)
1226
REQUIRED_INVENTORY_FIELDS = (
1327
"result",
1428
"normalized_sql",
@@ -20,41 +34,154 @@
2034
)
2135

2236

23-
def _validate_row(row, row_index, required_fields):
37+
def _validate_row_object(row, row_index):
2438
if not isinstance(row, dict):
2539
raise ValueError(f"row {row_index}: expected a JSON object")
2640

41+
for field in row:
42+
if not isinstance(field, str):
43+
raise ValueError(
44+
f"row {row_index}: field name {field!r} must be a string"
45+
)
46+
47+
48+
def _validate_required_fields(row, row_index, required_fields):
2749
for field in required_fields:
2850
if field not in row:
2951
raise ValueError(
3052
f"row {row_index}: missing required field {field!r}"
3153
)
3254

3355

34-
def _serialized_row_key(row, row_index):
35-
try:
36-
return json.dumps(
37-
row,
38-
ensure_ascii=False,
39-
separators=(",", ":"),
40-
sort_keys=True,
56+
def _validate_string_field(row, row_index, field):
57+
if not isinstance(row[field], str):
58+
raise ValueError(
59+
f"row {row_index}: field {field!r} must be a string"
4160
)
42-
except (TypeError, ValueError) as error:
61+
62+
63+
def _validate_integer_field(row, row_index, field, minimum):
64+
value = row[field]
65+
if isinstance(value, bool) or not isinstance(value, int):
66+
raise ValueError(
67+
f"row {row_index}: field {field!r} must be an integer"
68+
)
69+
if value < minimum:
4370
raise ValueError(
44-
f"row {row_index}: not JSON serializable: {error}"
45-
) from error
71+
f"row {row_index}: field {field!r} must be at least {minimum}"
72+
)
73+
74+
75+
def _strict_json_dumps(value):
76+
return json.dumps(
77+
value,
78+
allow_nan=False,
79+
ensure_ascii=False,
80+
separators=(",", ":"),
81+
sort_keys=True,
82+
)
83+
84+
85+
def _validate_json_fields(row, row_index):
86+
for field, value in row.items():
87+
try:
88+
serialized = _strict_json_dumps(value)
89+
except (OverflowError, RecursionError, TypeError, ValueError) as error:
90+
raise ValueError(
91+
f"row {row_index}: field {field!r} is not strict JSON "
92+
f"serializable: {error}"
93+
) from error
94+
95+
try:
96+
serialized.encode("utf-8")
97+
except UnicodeError as error:
98+
raise ValueError(
99+
f"row {row_index}: field {field!r} is not UTF-8 encodable: "
100+
f"{error}"
101+
) from error
102+
103+
104+
def _validate_result(row, row_index):
105+
_validate_required_fields(row, row_index, ("result",))
106+
_validate_string_field(row, row_index, "result")
107+
if row["result"] not in VALID_RESULTS:
108+
raise ValueError(
109+
f"row {row_index}: field 'result' has unsupported value "
110+
f"{row['result']!r}"
111+
)
112+
113+
114+
def _validate_optional_provenance(row, row_index):
115+
for field in ("branch", "commit"):
116+
if field in row:
117+
_validate_string_field(row, row_index, field)
118+
119+
120+
def _validate_accepted_row(row, row_index):
121+
_validate_row_object(row, row_index)
122+
_validate_required_fields(row, row_index, REQUIRED_INVENTORY_FIELDS)
123+
_validate_result(row, row_index)
124+
if row["result"] == "ORACLE_REJECTED":
125+
raise ValueError(
126+
f"row {row_index}: field 'result' must describe an accepted row"
127+
)
128+
129+
for field in ("normalized_sql", "oracle_node", "source_file", "sql"):
130+
_validate_string_field(row, row_index, field)
131+
_validate_integer_field(row, row_index, "offset", 0)
132+
_validate_integer_field(row, row_index, "line", 1)
133+
_validate_optional_provenance(row, row_index)
134+
_validate_json_fields(row, row_index)
135+
136+
137+
def _validate_diagnostic_row(row, row_index):
138+
_validate_row_object(row, row_index)
139+
_validate_result(row, row_index)
140+
if row["result"] != "ORACLE_REJECTED":
141+
raise ValueError(
142+
f"row {row_index}: field 'result' must be 'ORACLE_REJECTED'"
143+
)
144+
145+
for field in ("source_file", "sql", "oracle_error"):
146+
if field in row:
147+
_validate_string_field(row, row_index, field)
148+
if "offset" in row:
149+
_validate_integer_field(row, row_index, "offset", 0)
150+
if "line" in row:
151+
_validate_integer_field(row, row_index, "line", 1)
152+
_validate_optional_provenance(row, row_index)
153+
_validate_json_fields(row, row_index)
154+
155+
156+
def _validate_and_classify_row(row, row_index):
157+
_validate_row_object(row, row_index)
158+
_validate_result(row, row_index)
159+
if row["result"] == "ORACLE_REJECTED":
160+
_validate_diagnostic_row(row, row_index)
161+
return "diagnostic"
162+
163+
_validate_accepted_row(row, row_index)
164+
return "accepted"
165+
166+
167+
def _serialized_row_key(row):
168+
try:
169+
return _strict_json_dumps(row)
170+
except (OverflowError, RecursionError, TypeError, ValueError) as error:
171+
raise ValueError(f"row is not strict JSON serializable: {error}") from error
46172

47173

48174
def partition_rows(rows):
49175
accepted_rows = []
50176
diagnostics = []
51177

52178
for row_index, row in enumerate(rows):
53-
_validate_row(row, row_index, ("result",))
54-
if row["result"] == "ORACLE_REJECTED":
55-
diagnostics.append(row)
179+
classification = _validate_and_classify_row(row, row_index)
180+
row_copy = copy.deepcopy(row)
181+
if classification == "diagnostic":
182+
diagnostics.append(row_copy)
56183
else:
57-
accepted_rows.append(row)
184+
accepted_rows.append(row_copy)
58185

59186
return accepted_rows, diagnostics
60187

@@ -63,19 +190,18 @@ def build_inventory(rows):
63190
groups = {}
64191

65192
for row_index, row in enumerate(rows):
66-
_validate_row(row, row_index, ("result",))
67-
if row["result"] == "ORACLE_REJECTED":
193+
classification = _validate_and_classify_row(row, row_index)
194+
if classification == "diagnostic":
68195
continue
69196

70-
_validate_row(row, row_index, REQUIRED_INVENTORY_FIELDS)
71-
group_key = (row["oracle_node"], row["normalized_sql"])
197+
row_copy = copy.deepcopy(row)
198+
group_key = (row_copy["oracle_node"], row_copy["normalized_sql"])
72199
occurrence_key = (
73-
row["source_file"],
74-
row["offset"],
75-
row["line"],
200+
row_copy["source_file"],
201+
row_copy["offset"],
202+
row_copy["line"],
76203
)
77-
row_copy = dict(row)
78-
serialized_key = _serialized_row_key(row_copy, row_index)
204+
serialized_key = _serialized_row_key(row_copy)
79205
occurrences = groups.setdefault(group_key, {})
80206
existing = occurrences.get(occurrence_key)
81207
if existing is None or serialized_key < existing[0]:
@@ -112,18 +238,45 @@ def build_inventory(rows):
112238

113239

114240
def _jsonl_text(rows):
115-
serialized_rows = [
116-
json.dumps(
117-
row,
118-
ensure_ascii=False,
119-
separators=(",", ":"),
120-
sort_keys=True,
121-
)
122-
for row in rows
123-
]
241+
serialized_rows = [_strict_json_dumps(row) for row in rows]
124242
if not serialized_rows:
125243
return ""
126-
return "\n".join(serialized_rows) + "\n"
244+
245+
text = "\n".join(serialized_rows) + "\n"
246+
try:
247+
text.encode("utf-8")
248+
except UnicodeError as error:
249+
raise ValueError(f"output is not UTF-8 encodable: {error}") from error
250+
return text
251+
252+
253+
def _validate_distinct_paths(input_path, inventory_path, diagnostics_path):
254+
named_paths = (
255+
("--input", Path(input_path)),
256+
("--inventory", Path(inventory_path)),
257+
("--diagnostics", Path(diagnostics_path)),
258+
)
259+
resolved_paths = []
260+
for option, path in named_paths:
261+
try:
262+
resolved_paths.append((option, path, path.resolve(strict=False)))
263+
except (OSError, RuntimeError) as error:
264+
raise ValueError(f"{option} path cannot be resolved: {error}") from error
265+
266+
for index, (left_option, left_path, left_resolved) in enumerate(
267+
resolved_paths
268+
):
269+
for right_option, right_path, right_resolved in resolved_paths[index + 1 :]:
270+
aliases = left_resolved == right_resolved
271+
if not aliases:
272+
try:
273+
aliases = os.path.samefile(left_path, right_path)
274+
except OSError:
275+
aliases = False
276+
if aliases:
277+
raise ValueError(
278+
f"{left_option} and {right_option} must identify distinct files"
279+
)
127280

128281

129282
def _argument_parser():
@@ -139,6 +292,11 @@ def main(argv=None):
139292
arguments = parser.parse_args(argv)
140293

141294
try:
295+
_validate_distinct_paths(
296+
arguments.input,
297+
arguments.inventory,
298+
arguments.diagnostics,
299+
)
142300
rows = list(read_jsonl(arguments.input))
143301
accepted_rows, diagnostics = partition_rows(rows)
144302
inventory = build_inventory(accepted_rows)
@@ -147,8 +305,11 @@ def main(argv=None):
147305
except (OSError, TypeError, ValueError) as error:
148306
parser.exit(1, f"error: {error}\n")
149307

150-
atomic_write_text(arguments.inventory, inventory_text)
151-
atomic_write_text(arguments.diagnostics, diagnostics_text)
308+
try:
309+
atomic_write_text(arguments.inventory, inventory_text)
310+
atomic_write_text(arguments.diagnostics, diagnostics_text)
311+
except (OSError, UnicodeError) as error:
312+
parser.exit(1, f"error: cannot publish outputs: {error}\n")
152313

153314

154315
if __name__ == "__main__":

0 commit comments

Comments
 (0)