Skip to content

Commit f99923c

Browse files
committed
fix: harden libpg_query differential runner
1 parent 38ae2a5 commit f99923c

2 files changed

Lines changed: 525 additions & 88 deletions

File tree

tests/pg_compat/test_runner.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import json
2+
import os
3+
from pathlib import Path
4+
import subprocess
5+
import tempfile
6+
import unittest
7+
8+
9+
RUNNER_VALUE = os.environ.get("PG_COMPAT_RUNNER")
10+
if not RUNNER_VALUE:
11+
raise RuntimeError(
12+
"PG_COMPAT_RUNNER must name the compiled pg_compat runner"
13+
)
14+
15+
RUNNER = Path(RUNNER_VALUE)
16+
REPO_ROOT = Path(__file__).resolve().parents[2]
17+
FIXTURE = REPO_ROOT / "tests" / "pg_compat" / "runner_cases.sql"
18+
USAGE = "Usage: pg_compat_runner --input FILE --branch NAME --commit SHA\n"
19+
TIMEOUT_SECONDS = 5
20+
21+
22+
class RunnerTest(unittest.TestCase):
23+
@classmethod
24+
def setUpClass(cls):
25+
if not RUNNER.is_file():
26+
raise RuntimeError(f"PG_COMPAT_RUNNER does not exist: {RUNNER}")
27+
28+
def run_runner(self, *arguments, **kwargs):
29+
return subprocess.run(
30+
[str(RUNNER), *map(str, arguments)],
31+
capture_output=True,
32+
timeout=TIMEOUT_SECONDS,
33+
**kwargs,
34+
)
35+
36+
def run_sql(self, contents, *, branch="test", commit="deadbeef"):
37+
data = contents if isinstance(contents, bytes) else contents.encode()
38+
with tempfile.NamedTemporaryFile(suffix=".sql") as sql_file:
39+
sql_file.write(data)
40+
sql_file.flush()
41+
return self.run_runner(
42+
"--input",
43+
sql_file.name,
44+
"--branch",
45+
branch,
46+
"--commit",
47+
commit,
48+
)
49+
50+
@staticmethod
51+
def json_rows(result):
52+
return [
53+
json.loads(line)
54+
for line in result.stdout.decode("utf-8").splitlines()
55+
]
56+
57+
def test_committed_fixture(self):
58+
result = self.run_runner(
59+
"--input",
60+
FIXTURE,
61+
"--branch",
62+
"18-latest",
63+
"--commit",
64+
"fixture-commit",
65+
)
66+
67+
self.assertEqual(result.returncode, 0, result.stderr.decode())
68+
self.assertEqual(result.stderr, b"")
69+
rows = self.json_rows(result)
70+
self.assertEqual(len(rows), 5)
71+
self.assertEqual(
72+
[row["result"] for row in rows],
73+
[
74+
"DEEP_SUPPORTED",
75+
"CLASSIFIED_ONLY",
76+
"TYPE_MISMATCH",
77+
"DEEP_SUPPORTED",
78+
"DEEP_SUPPORTED",
79+
],
80+
)
81+
self.assertEqual([row["offset"] for row in rows], [0, 35, 68, 82, 92])
82+
self.assertEqual([row["line"] for row in rows], [1, 2, 3, 4, 4])
83+
self.assertEqual([row["sql"] for row in rows[-2:]], ["SELECT 1", "SELECT 2"])
84+
self.assertTrue(all(row["source_file"] == str(FIXTURE) for row in rows))
85+
self.assertTrue(all(row["branch"] == "18-latest" for row in rows))
86+
self.assertTrue(all(row["commit"] == "fixture-commit" for row in rows))
87+
required_fields = {
88+
"source_file",
89+
"offset",
90+
"line",
91+
"splitter",
92+
"sql",
93+
"normalized_sql",
94+
"oracle_node",
95+
"expected_stmt_type",
96+
"parser_status",
97+
"parser_stmt_type",
98+
"has_ast",
99+
"remaining",
100+
"result",
101+
"branch",
102+
"commit",
103+
}
104+
self.assertTrue(all(required_fields <= row.keys() for row in rows))
105+
self.assertTrue(
106+
all(not row["remaining"].strip(" \t\r\n\f\v;") for row in rows)
107+
)
108+
109+
def test_cli_no_arguments(self):
110+
result = self.run_runner()
111+
112+
self.assertEqual(result.returncode, 2)
113+
self.assertEqual(result.stdout, b"")
114+
self.assertEqual(result.stderr.decode(), USAGE)
115+
116+
def test_cli_missing_input_file(self):
117+
missing = Path(tempfile.gettempdir()) / "pg-compat-missing-input.sql"
118+
missing.unlink(missing_ok=True)
119+
120+
result = self.run_runner(
121+
"--input",
122+
missing,
123+
"--branch",
124+
"test",
125+
"--commit",
126+
"deadbeef",
127+
)
128+
129+
self.assertEqual(result.returncode, 1)
130+
self.assertEqual(result.stdout, b"")
131+
self.assertEqual(
132+
result.stderr.decode(),
133+
f"infrastructure error: cannot open input file: {missing}\n",
134+
)
135+
136+
def test_cli_rejects_unknown_missing_and_duplicate_options(self):
137+
argument_cases = [
138+
["--input", FIXTURE, "--branch", "test"],
139+
[
140+
"--input",
141+
FIXTURE,
142+
"--branch",
143+
"test",
144+
"--commit",
145+
"deadbeef",
146+
"--extra",
147+
"value",
148+
],
149+
[
150+
"--input",
151+
FIXTURE,
152+
"--input",
153+
FIXTURE,
154+
"--branch",
155+
"test",
156+
"--commit",
157+
"deadbeef",
158+
],
159+
[FIXTURE, "--branch", "test", "--commit", "deadbeef"],
160+
[
161+
"--input",
162+
FIXTURE,
163+
"--branch",
164+
"",
165+
"--commit",
166+
"deadbeef",
167+
],
168+
[
169+
"--input",
170+
FIXTURE,
171+
"--branch",
172+
"--unknown",
173+
"--commit",
174+
"deadbeef",
175+
],
176+
]
177+
178+
for arguments in argument_cases:
179+
with self.subTest(arguments=arguments):
180+
result = self.run_runner(*arguments)
181+
self.assertEqual(result.returncode, 2)
182+
self.assertEqual(result.stdout, b"")
183+
self.assertEqual(result.stderr.decode(), USAGE)
184+
185+
def test_scanner_fallback_uses_physical_code_lines(self):
186+
result = self.run_sql(
187+
"SELECT FROM;\n"
188+
"-- comment before second\n"
189+
"SELECT 1;\n"
190+
"/* outer\n"
191+
" * /* nested */\n"
192+
" */\n"
193+
"SELECT 2;\n"
194+
)
195+
196+
self.assertEqual(result.returncode, 0, result.stderr.decode())
197+
rows = self.json_rows(result)
198+
self.assertEqual(
199+
[row["result"] for row in rows],
200+
["ORACLE_REJECTED", "DEEP_SUPPORTED", "DEEP_SUPPORTED"],
201+
)
202+
self.assertTrue(all(row["splitter"] == "scanner" for row in rows))
203+
self.assertEqual([row["line"] for row in rows[1:]], [3, 7])
204+
self.assertEqual(
205+
[row["sql"].strip()[-8:] for row in rows[1:]],
206+
["SELECT 1", "SELECT 2"],
207+
)
208+
209+
def test_unmapped_node_is_infrastructure_failure(self):
210+
result = self.run_sql("LOAD 'foo';\n")
211+
212+
self.assertNotEqual(result.returncode, 0)
213+
self.assertEqual(result.stdout, b"")
214+
self.assertIn(
215+
"PG_QUERY__NODE__NODE_LOAD_STMT",
216+
result.stderr.decode(),
217+
)
218+
219+
def test_comments_do_not_affect_normalization(self):
220+
result = self.run_sql(
221+
"SELECT 1;\n"
222+
"SELECT -- line comment\n"
223+
" 1;\n"
224+
"SELECT /* block comment */ 1;\n"
225+
"SELECT /* outer /* nested */ block */ 1;\n"
226+
)
227+
228+
self.assertEqual(result.returncode, 0, result.stderr.decode())
229+
rows = self.json_rows(result)
230+
self.assertEqual(len(rows), 4)
231+
self.assertEqual(
232+
[row["normalized_sql"] for row in rows],
233+
["SELECT 1"] * 4,
234+
)
235+
236+
def test_json_escaping_preserves_valid_utf8_and_controls(self):
237+
sql = "SELECT 'quote \" slash \\\\ tab\t control\x01\nnext café';\n"
238+
result = self.run_sql(
239+
sql,
240+
branch='branch"quoted',
241+
commit="commit\\slash",
242+
)
243+
244+
self.assertEqual(result.returncode, 0, result.stderr.decode())
245+
self.assertNotIn(b"\x01", result.stdout)
246+
self.assertIn(b"\\u0001", result.stdout)
247+
rows = self.json_rows(result)
248+
self.assertEqual(len(rows), 1)
249+
self.assertEqual(rows[0]["sql"], sql[:-2])
250+
self.assertEqual(rows[0]["branch"], 'branch"quoted')
251+
self.assertEqual(rows[0]["commit"], "commit\\slash")
252+
self.assertIn("café", rows[0]["sql"])
253+
254+
def test_oracle_rejection_is_a_record(self):
255+
result = self.run_sql("SELECT FROM;\nSELECT 1;\n")
256+
257+
self.assertEqual(result.returncode, 0, result.stderr.decode())
258+
rows = self.json_rows(result)
259+
self.assertEqual(len(rows), 2)
260+
self.assertEqual(rows[0]["result"], "ORACLE_REJECTED")
261+
self.assertTrue(rows[0]["oracle_error"])
262+
self.assertEqual(rows[1]["result"], "DEEP_SUPPORTED")
263+
264+
def test_dual_splitter_failure_is_infrastructure_error(self):
265+
result = self.run_sql("SELECT 'unterminated")
266+
267+
self.assertEqual(result.returncode, 1)
268+
self.assertEqual(result.stdout, b"")
269+
self.assertTrue(
270+
result.stderr.decode().startswith(
271+
"infrastructure error: statement splitting failed; parser: "
272+
)
273+
)
274+
self.assertIn("; scanner: ", result.stderr.decode())
275+
276+
def test_embedded_nul_is_rejected_before_output(self):
277+
result = self.run_sql(b"SELECT 1;\x00SELECT 2;")
278+
279+
self.assertEqual(result.returncode, 1)
280+
self.assertEqual(result.stdout, b"")
281+
self.assertIn(
282+
"input contains a NUL byte at offset 9",
283+
result.stderr.decode(),
284+
)
285+
286+
def test_malformed_utf8_is_rejected_before_output(self):
287+
result = self.run_sql(b"SELECT '\xff';\n")
288+
289+
self.assertEqual(result.returncode, 1)
290+
self.assertEqual(result.stdout, b"")
291+
self.assertIn("invalid UTF-8 at byte offset 8", result.stderr.decode())
292+
293+
@unittest.skipUnless(os.name == "posix", "requires POSIX file descriptors")
294+
def test_closed_stdout_is_nonzero(self):
295+
def close_stdout():
296+
os.close(1)
297+
298+
result = subprocess.run(
299+
[
300+
str(RUNNER),
301+
"--input",
302+
str(FIXTURE),
303+
"--branch",
304+
"closed",
305+
"--commit",
306+
"stdout",
307+
],
308+
stdout=subprocess.PIPE,
309+
stderr=subprocess.PIPE,
310+
preexec_fn=close_stdout,
311+
timeout=TIMEOUT_SECONDS,
312+
)
313+
314+
self.assertNotEqual(result.returncode, 0, result.stderr.decode())
315+
self.assertEqual(result.stdout, b"")
316+
317+
318+
if __name__ == "__main__":
319+
unittest.main()

0 commit comments

Comments
 (0)