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]