File size: 19,881 Bytes
2a87ede | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 | """
Unit tests for dense_evolution/parser.py -- QASMParser (2.0/3.0 parsing,
range syntax, for-loops, validate(), AST-sandboxed expression evaluation)
and QASMCircuit's iterability.
Split out of the original monolithic test_dense_evolution.py -- see
test_simulator.py's module docstring for why.
"""
import numpy as np
import pytest
from dense_evolution import DenseSVSimulator, QASMParser, QASMCircuit, Chunk, QuantumTranspiler
from _helpers import probs
def test_backward_compat_shim_parser_reexports_qasmparser_and_qasmcircuit():
# dense_evolution.parser is the Phase 2 backward-compat shim left at
# the old top-level path -- nothing else in this suite imports through
# it (this file, like everything else, gets QASMParser/QASMCircuit via
# the top-level dense_evolution package, which now sources them from
# dense_evolution.circuits.parser directly), so without this the
# shim's own lines go uncovered and a broken shim would go undetected
# by CI.
from dense_evolution.parser import QASMParser as shim_qasmparser, QASMCircuit as shim_qasmcircuit
assert shim_qasmparser is QASMParser
assert shim_qasmcircuit is QASMCircuit
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# QASM RANGE SYNTAX
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestQASMRangeSyntax:
"""Regression guard for audit finding #2: `gate q[a:b]` on an inherently
single-qubit gate used to attach all resolved qubits to ONE op, so only
the first qubit was ever actually gated β the rest were silently dropped
with no error, and probabilities still summed to 1. The parser's own
docstring already promised "range syntax expanded to individual qubits";
parse() now honors that by emitting one op per qubit instead of one op
carrying the whole list."""
def test_range_syntax_expands_to_separate_ops(self):
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[4]; h q[0:3];'
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 3
assert [op['qubits'] for op in circ.ops] == [[0], [1], [2]]
assert all(op['name'] == 'h' for op in circ.ops)
def test_range_syntax_produces_correct_superposition(self, sim4):
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[4]; h q[0:3];'
circ = QASMParser().parse(qasm)
sim4.run_circuit_jit([[op['name'], op['qubits'][0], -1] for op in circ.ops])
p = probs(sim4)
# q0,q1,q2 uniform superposition, q3 untouched -> 8 equally likely states
nonzero = np.where(p > 1e-9)[0]
assert len(nonzero) == 8
assert np.allclose(p[nonzero], 1.0 / 8, atol=1e-9)
def test_two_qubit_gate_qubit_list_is_not_expanded(self):
# sanity check the fix is scoped to single-qubit gate names only β
# a genuine 2-qubit gate must keep both its qubits on one op
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; cx q[0],q[1];'
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 1
assert circ.ops[0]['qubits'] == [0, 1]
class TestQASMForLoop:
"""QASM 3.0 `for`-loops are brace-delimited, not ';'-terminated β the
parser used to split statements on ';' alone, so a `for ... { ... }`
block both lost its own body (never extracted) AND corrupted whatever
real statement followed it on the same line (the stray closing '}'
merged with the next statement's text into one garbage op). Verified
directly: `for int i in [0:2] { h q[i]; } cx q[0],q[1];` used to produce
a single ghost op named '}' and silently drop both the loop body and
the real cx β the executed circuit stayed |000> at 100% probability
with no error. _process_block_constructs now unrolls resolvable `for`
loops and cleanly strips `if`/`while`/`def` blocks before the ';'-split
ever runs, needed for VQE ansΓ€tze written with a loop over qubits."""
def test_for_loop_body_extracted_and_following_gate_preserved(self):
qasm = '''
qreg q[3];
for int i in [0:2] { h q[i]; }
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 4
assert [op['name'] for op in circ.ops] == ['h', 'h', 'h', 'cx']
assert [op['qubits'] for op in circ.ops] == [[0], [1], [2], [0, 1]]
def test_for_loop_executes_to_real_ghz_not_ghost_op(self, sim3):
qasm = '''
qreg q[3];
for int i in [0:2] { h q[i]; }
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm)
sim3.run_circuit(circ.to_tuples())
p = probs(sim3)
# not the pre-fix bug (|000> at 100%): real superposition present
assert p[0] < 0.99
def test_for_loop_bound_resolved_from_declared_int_variable(self):
qasm = '''
int n = 3;
qreg q[3];
for int i in [0:n-1] { rx(0.5) q[i]; }
'''
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 3
assert all(op['name'] == 'rx' and op['params'] == [0.5] for op in circ.ops)
assert [op['qubits'] for op in circ.ops] == [[0], [1], [2]]
def test_for_range_is_inclusive_of_end_bound(self):
# QASM3 for-range [0:2] must cover indices 0,1,2 (three iterations) β
# unlike this parser's own EXCLUSIVE q[a:b] qubit-range syntax.
qasm = 'qreg q[3]; for i in [0:2] { x q[i]; }'
circ = QASMParser().parse(qasm)
assert [op['qubits'][0] for op in circ.ops] == [0, 1, 2]
def test_for_loop_body_with_multiple_statements_expands_all(self):
qasm = 'qreg q[2]; for i in [0:1] { h q[i]; x q[i]; }'
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['h', 'x', 'h', 'x']
assert [op['qubits'][0] for op in circ.ops] == [0, 0, 1, 1]
def test_if_block_does_not_corrupt_following_statement(self):
qasm = 'qreg q[2]; if (c==1) { x q[0]; } h q[1];'
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 1
assert circ.ops[0] == {'type': 'gate', 'name': 'h', 'qubits': [1], 'params': []}
def test_no_block_constructs_is_a_no_op(self):
# plain circuits with no for/if/while/def must be completely
# unaffected by _process_block_constructs
qasm = 'qreg q[2]; h q[0]; cx q[0], q[1];'
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['h', 'cx']
# -- unresolvable-bound / multi-construct coverage --------------------
# Area verified separately (RAM-unconstrained environment): an
# unresolvable `for` bound falls through to the exact same
# `replacement = ''` strip path as if/while/def (see
# _process_block_constructs docstring) -- these tests exercise that
# specific trigger (an undeclared bound variable, so
# _resolve_int_expr returns None) rather than assuming the shared
# code path is equivalent without checking.
def test_unresolvable_for_loop_bound_stripped_following_gate_preserved(self):
# 'n' is never declared -- _resolve_int_expr must return None for
# it (confirmed by reading _eval_ast_node: an ast.Name not in env
# falls through to the final `raise`, caught by _resolve_int_expr's
# except-Exception), so this for loop takes the strip path, not
# the unroll path.
qasm = '''
qreg q[3];
for int i in [0:n] { h q[i]; }
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['cx']
assert circ.ops[0]['qubits'] == [0, 1]
def test_unresolvable_for_loop_stripped_execution_matches_bare_circuit(self, sim3):
# Same pattern as the v8.1.13 regression tests: compare actual
# probabilities, not just the op list, against an equivalent
# circuit written without the unresolvable loop at all.
qasm_with_loop = '''
qreg q[3];
for int i in [0:n] { h q[i]; }
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm_with_loop)
sim3.run_circuit(circ.to_tuples())
p_with_loop = probs(sim3)
ref = DenseSVSimulator(n_qubits=3, use_gpu=False, use_float32=False)
ref_circ = QASMParser().parse('qreg q[3]; cx q[0], q[1];')
ref.run_circuit(ref_circ.to_tuples())
p_ref = probs(ref)
np.testing.assert_allclose(p_with_loop, p_ref, atol=1e-12)
def test_while_block_does_not_corrupt_following_statement(self):
qasm = 'qreg q[2]; while (c==1) { x q[0]; } h q[1];'
circ = QASMParser().parse(qasm)
assert len(circ.ops) == 1
assert circ.ops[0] == {'type': 'gate', 'name': 'h', 'qubits': [1], 'params': []}
def test_multiple_unresolvable_constructs_in_sequence(self):
# for (unresolvable) + if + while, each stripped in turn, valid
# gates interleaved between and after every one of them survive.
qasm = '''
qreg q[3];
h q[0];
for int i in [0:n] { x q[i]; }
x q[1];
if (c==1) { y q[0]; }
y q[2];
while (c==1) { z q[0]; }
cx q[0], q[2];
'''
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['h', 'x', 'y', 'cx']
assert [op['qubits'] for op in circ.ops] == [[0], [1], [2], [0, 2]]
def test_resolvable_for_then_unresolvable_if_then_valid_code(self):
# Combination the changelog's original fix never exercised: a
# resolvable for-loop (real unrolling, not stripping) immediately
# followed by an unresolvable-condition if (stripping) followed by
# more valid code -- confirms the unroll doesn't shift/corrupt the
# search position _process_block_constructs uses to find the next
# block.
qasm = '''
qreg q[3];
for int i in [0:1] { h q[i]; }
if (some_undeclared_condition) { x q[2]; }
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['h', 'h', 'cx']
assert [op['qubits'] for op in circ.ops] == [[0], [1], [0, 1]]
class TestQASMParserValidateAndEdgeCases:
"""QASMParser.validate() was never called by any existing test (only
parse() itself), plus a handful of parser edge-case branches (QASM3
`bit[N]` classical register syntax, unbalanced braces/parentheses,
multi-parameter gates, and out-of-declared-range indexed qubits)."""
def test_validate_accepts_well_formed_circuit(self):
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; h q[0]; cx q[0],q[1];'
circ = QASMParser().parse(qasm)
ok, msg = QASMParser().validate(circ)
assert ok is True
assert msg == 'OK'
def test_validate_rejects_zero_qubits(self):
empty = QASMCircuit(0, 0, [])
ok, msg = QASMParser().validate(empty)
assert ok is False
assert 'n_qubits' in msg
def test_validate_rejects_no_ops(self):
no_ops = QASMCircuit(2, 0, [])
ok, msg = QASMParser().validate(no_ops)
assert ok is False
assert 'No gate operations' in msg
def test_validate_rejects_out_of_range_qubit_reference(self):
bad = QASMCircuit(2, 0, [{'type': 'gate', 'name': 'h', 'qubits': [5], 'params': []}])
ok, msg = QASMParser().validate(bad)
assert ok is False
assert 'qubit 5' in msg
def test_qasm3_bit_declaration(self):
qasm = 'OPENQASM 3.0; qreg q[2]; bit[2] c; h q[0]; cx q[0],q[1];'
circ = QASMParser().parse(qasm)
assert circ.n_cbits == 2
assert [op['name'] for op in circ.ops] == ['h', 'cx']
def test_unbalanced_parentheses_in_gate_call_skipped(self):
# 'rx(0.5 q[0];' -- missing closing paren -- must be skipped
# (returns None internally, no op emitted), not raise.
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; rx(0.5 q[0]; h q[1];'
circ = QASMParser().parse(qasm)
assert [op['name'] for op in circ.ops] == ['h']
def test_unbalanced_braces_in_for_loop_bail_out(self):
# A for-loop construct with a missing closing brace must not hang
# or raise -- _process_block_constructs bails out and the rest is
# handled by whatever the existing fallback does.
qasm = '''
OPENQASM 3.0;
qreg q[2];
for int i in [0:1] { h q[i];
cx q[0], q[1];
'''
circ = QASMParser().parse(qasm) # must not hang/raise
assert isinstance(circ.ops, list)
def test_multi_parameter_gate_comma_split(self):
# u2(phi, lam) -- a real 2-parameter gate, exercises _split_params's
# depth==0 comma-splitting branch (every other parametric-gate test
# elsewhere in this file uses a single parameter).
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[1]; u2(0.1,0.2) q[0];'
circ = QASMParser().parse(qasm)
assert circ.ops[0]['name'] == 'u2'
assert circ.ops[0]['params'] == pytest.approx([0.1, 0.2])
def test_indexed_qubit_beyond_declared_range_uses_numeric_fallback(self):
# q[5] on a 2-qubit qreg -- not in qmap, falls back to the literal
# index rather than being dropped (BUG FIX 7's documented gate,
# only for tokens with no letters).
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; h q[5];'
circ = QASMParser().parse(qasm)
assert circ.ops[0]['qubits'] == [5]
class TestQASMCircuitIterable:
"""Found via a user's own Colab testing: Chunk.run_chunk(circuit) (and
QuantumTranspiler.transpile, which it calls internally) iterates
directly over its `circuit` argument β `for cmd in circuit`. Passing a
QASMCircuit straight from QASMParser().parse(...) (instead of calling
.to_tuples() first) used to raise `TypeError: 'QASMCircuit' object is
not iterable`, a real usability gap for a very natural usage pattern.
Fixed by adding __iter__, duck-typing QASMCircuit as an iterable of the
same tuples to_tuples() already returns β no existing call site inside
dense_evolution relied on QASMCircuit being non-iterable."""
def test_iterating_a_qasmcircuit_matches_to_tuples(self):
circ = QASMParser().parse('qreg q[2]; h q[0]; cx q[0],q[1]; rz(0.5) q[1];')
assert list(circ) == circ.to_tuples()
def test_chunk_run_chunk_accepts_a_bare_qasmcircuit(self):
circ = QASMParser().parse('qreg q[2]; h q[0]; cx q[0],q[1];')
ch = Chunk(2)
ch.run_chunk(circ) # used to raise TypeError without __iter__
probs_ = np.asarray(ch.get_probabilities())
assert abs(probs_.sum() - 1.0) < 1e-9
def test_transpile_accepts_a_bare_qasmcircuit(self):
circ = QASMParser().parse('qreg q[3]; ccx q[0],q[1],q[2];')
expanded = QuantumTranspiler.transpile(circ)
assert len(expanded) == 15
class TestParserEvalSecurity:
"""_eval_param (gate parameters) and _resolve_int_expr (for-loop bounds)
used to call raw eval() with only `{'__builtins__': {}}` as protection β
that blocks bare builtin names (open, len, __import__...) but does
NOT block attribute/dunder traversal of the live object graph, which
needs no builtin name at all. Verified directly: a gate parameter of
`().__class__.__bases__[0].__subclasses__().__len__()`, passed through
the public QASMParser.parse() entry point, executed successfully and
returned a real value (2200.0, the live subclass count) before this
fix β a genuine code-execution vulnerability, not a hypothetical one.
Both now go through _eval_ast_node, an AST node-type whitelist with no
eval()/exec() anywhere β an attribute access is an ast.Attribute node,
which is never one of the handled cases, so '.' in an expression always
lands in the rejection branch structurally, not via a blocklist."""
_ESCAPE_PAYLOADS = [
'().__class__.__bases__[0].__subclasses__().__len__()',
'__import__("os").system("echo pwned")',
'getattr(1, "__class__")',
'[x for x in range(10)][0]',
'(lambda: 1)()',
'exec("1")',
'globals()',
'().__class__.__init__.__globals__',
]
@pytest.mark.parametrize('payload', _ESCAPE_PAYLOADS)
def test_eval_param_blocks_sandbox_escapes(self, payload):
# _eval_param used to swallow every rejected expression into a
# silent 0.0 (same fallback a genuine typo like 'pi * / 2' hit
# too); it now raises ValueError instead -- still structurally
# blocked (the AST whitelist never reaches these nodes), just
# explicit about it instead of silent, same as a malformed
# expression from a typo.
with pytest.raises(ValueError):
QASMParser()._eval_param(payload)
@pytest.mark.parametrize('payload', _ESCAPE_PAYLOADS)
def test_resolve_int_expr_blocks_sandbox_escapes(self, payload):
assert QASMParser()._resolve_int_expr(payload, {}) is None
def test_original_exploit_through_full_parse_raises(self):
# end-to-end through the actual public entry point, not just the
# internal method directly
qasm = ('OPENQASM 3.0; qubit[1] q; '
'rx(().__class__.__bases__[0].__subclasses__().__len__()) q[0];')
with pytest.raises(ValueError):
QASMParser().parse(qasm)
def test_original_exploit_in_for_loop_bound_yields_no_ops(self):
qasm = ('OPENQASM 3.0; qubit[1] q; '
'for int i in [0:().__class__.__bases__[0].__subclasses__().__len__()] '
'{ x q[0]; }')
circ = QASMParser().parse(qasm)
assert circ.ops == []
@pytest.mark.parametrize('expr,expected', [
('pi', np.pi), ('pi/2', np.pi / 2), ('-pi/4', -np.pi / 4),
('pi/8', np.pi / 8), ('0.5', 0.5), ('-0.5', -0.5),
('sqrt(2)', np.sqrt(2)), ('cos(0.3)', np.cos(0.3)),
('sin(pi/4)*2', np.sin(np.pi / 4) * 2),
('2*pi/3', 2 * np.pi / 3), ('1+2*3', 7.0),
])
def test_legitimate_expressions_unchanged(self, expr, expected):
assert QASMParser()._eval_param(expr) == pytest.approx(expected)
@pytest.mark.parametrize('malformed', [
'pi * / 2', 'pi +', '(pi', 'pi 2', '**pi', 'pi // 2',
])
def test_malformed_expression_raises_instead_of_silent_zero(self, malformed):
# Found via an external code-review report, reproduced directly:
# 'rx(pi * / 2) q[0];' used to parse successfully and silently
# produce rx(0.0) -- a different, valid circuit, no signal a typo
# happened. Now raises instead of hiding the mistake.
with pytest.raises(ValueError):
QASMParser()._eval_param(malformed)
def test_malformed_expression_through_full_parse_raises(self):
qasm = 'OPENQASM 2.0; include "qelib1.inc"; qreg q[1]; rx(pi * / 2) q[0];'
with pytest.raises(ValueError):
QASMParser().parse(qasm)
def test_legitimate_for_loop_bounds_unchanged(self):
qasm = 'qreg q[3]; int n = 3; for int i in [0:n-1] { x q[i]; }'
circ = QASMParser().parse(qasm)
assert [op['qubits'][0] for op in circ.ops] == [0, 1, 2]
|