quantbom 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbom/__init__.py +497 -0
- quantbom/_bitorder.py +121 -0
- quantbom/_circuit.py +77 -0
- quantbom/_collect.py +661 -0
- quantbom/_drift.py +333 -0
- quantbom/_fidelity.py +280 -0
- quantbom/_merge.py +187 -0
- quantbom/_qir.py +224 -0
- quantbom/_scan.py +118 -0
- quantbom/_sign.py +284 -0
- quantbom/_stats.py +125 -0
- quantbom/_verify.py +530 -0
- quantbom/actions/__init__.py +27 -0
- quantbom/actions/base.py +68 -0
- quantbom/actions/benchmark.py +53 -0
- quantbom/actions/bind_backend.py +71 -0
- quantbom/actions/capture.py +106 -0
- quantbom/actions/check_qir.py +44 -0
- quantbom/actions/check_results.py +75 -0
- quantbom/actions/compare_native.py +88 -0
- quantbom/actions/disclose_error_handling.py +66 -0
- quantbom/actions/execute.py +85 -0
- quantbom/actions/inventory.py +24 -0
- quantbom/actions/layout.py +118 -0
- quantbom/actions/prove_equivalence.py +225 -0
- quantbom/actions/record_provenance.py +49 -0
- quantbom/actions/verify_conversion.py +53 -0
- quantbom/attest.py +363 -0
- quantbom/cli.py +378 -0
- quantbom/frameworks/__init__.py +67 -0
- quantbom/frameworks/base.py +155 -0
- quantbom/frameworks/braket.py +132 -0
- quantbom/frameworks/cirq.py +106 -0
- quantbom/frameworks/cudaq.py +223 -0
- quantbom/frameworks/pennylane.py +116 -0
- quantbom/frameworks/qiskit.py +67 -0
- quantbom/frameworks/qrisp.py +267 -0
- quantbom/harness.json +1730 -0
- quantbom/harness.py +88 -0
- quantbom-0.6.0.dist-info/METADATA +207 -0
- quantbom-0.6.0.dist-info/RECORD +45 -0
- quantbom-0.6.0.dist-info/WHEEL +5 -0
- quantbom-0.6.0.dist-info/entry_points.txt +2 -0
- quantbom-0.6.0.dist-info/licenses/LICENSE +202 -0
- quantbom-0.6.0.dist-info/top_level.txt +1 -0
quantbom/cli.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""QuantBOM CLI — certify a quantum release from your code, a file or the built-in demo.
|
|
3
|
+
|
|
4
|
+
export QUANTBOM_API_KEY=qb_live_...
|
|
5
|
+
quantbom certify --entry my_pkg.circuits:build --release my-app@1.0 # any supported framework
|
|
6
|
+
quantbom certify --qpy circuit.qpy --release my-app@1.0
|
|
7
|
+
quantbom certify --qasm3 program.qasm3 --release my-app@1.0
|
|
8
|
+
quantbom certify --demo --release smoke-test
|
|
9
|
+
|
|
10
|
+
# sign the evidence as its producer (the authorship claim CRA-SIGN-01 rests on)
|
|
11
|
+
quantbom keygen # once: ~/.quantbom/signing.key
|
|
12
|
+
quantbom certify --entry my_pkg.circuits:build --release my-app@1.0 --sign
|
|
13
|
+
|
|
14
|
+
# two-step, when partner evidence or a signature joins the bundle first
|
|
15
|
+
quantbom collect --entry my_pkg.circuits:build --release my-app@1.0 --out bundle/
|
|
16
|
+
python emit_test_evidence.py emit ... --inject-sbom bundle/sbom.cdx.json
|
|
17
|
+
quantbom attest tests --bundle bundle/ --suite quantum-security-suite --outcome passed --passed 42
|
|
18
|
+
quantbom attest vulns --bundle bundle/ --policy-url https://example.org/security --contact mailto:security@example.org
|
|
19
|
+
quantbom attest constant-time --bundle bundle/ --tool dudect --target kyber_decaps --measurements timings.csv
|
|
20
|
+
quantbom attest crypto --bundle bundle/ --cbom cbom.cdx.json
|
|
21
|
+
quantbom sign --bundle bundle/ # last: the signature covers what the attests added
|
|
22
|
+
quantbom certify --bundle bundle/ --release my-app@1.0
|
|
23
|
+
|
|
24
|
+
# an auditor, offline: re-derive digests, signatures and bindings; the service key must be anchored
|
|
25
|
+
quantbom keys --pin # fetch https://certify.zortenet.eu/v1/keys once, pin it
|
|
26
|
+
quantbom verify .quantbom/my-app@1.0 # or a zip of the mirror; exit 0 only on VERIFIED
|
|
27
|
+
|
|
28
|
+
# what can the harness do for my framework / for this requirement?
|
|
29
|
+
quantbom actions --framework cirq
|
|
30
|
+
quantbom actions --requirement CRA-II-3
|
|
31
|
+
|
|
32
|
+
# did a toolchain change alter what my programs mean? (semantic drift sentinel)
|
|
33
|
+
quantbom drift --corpus drift_corpus/programs.py # exit 1 on semantic drift
|
|
34
|
+
quantbom drift --corpus drift_corpus/programs.py --update-baseline
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from . import STRICT_WITHHELD_REFS, certify, collect
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _add_source(p: argparse.ArgumentParser, *, bundle: bool) -> None:
|
|
47
|
+
src = p.add_mutually_exclusive_group(required=True)
|
|
48
|
+
src.add_argument("--entry", metavar="MODULE:ATTR",
|
|
49
|
+
help="circuit object from your code (module:attr or file.py:attr; a function is called) — "
|
|
50
|
+
"Qiskit, Qrisp, Cirq, Braket, PennyLane or CUDA-Q")
|
|
51
|
+
src.add_argument("--qpy", help="Qiskit-native circuit (QPY file)")
|
|
52
|
+
src.add_argument("--qasm3", help="OpenQASM3 file")
|
|
53
|
+
src.add_argument("--demo", action="store_true", help="use the built-in Qrisp demo circuit")
|
|
54
|
+
if bundle:
|
|
55
|
+
src.add_argument("--bundle", metavar="DIR", help="certify a bundle made by `quantbom collect` (nothing is re-collected)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add_collection(p: argparse.ArgumentParser) -> None:
|
|
59
|
+
p.add_argument("--release", required=True)
|
|
60
|
+
p.add_argument("--backend", default="basic", choices=["basic", "generic", "ibm"],
|
|
61
|
+
help="basic: local ideal simulator · generic: synthetic seeded device (GenericBackendV2; "
|
|
62
|
+
"noise with quantbom[sim]) · ibm: real hardware (credentials needed)")
|
|
63
|
+
p.add_argument("--environment", "-E", action="store_true",
|
|
64
|
+
help="merge a syft scan of the running Python environment into the QBOM (needs syft on PATH)")
|
|
65
|
+
p.add_argument("--syft-sbom", help="merge a pre-generated CycloneDX environment SBOM (syft/trivy/cdxgen JSON)")
|
|
66
|
+
p.add_argument("--vuln-scan", "-V", action="store_true",
|
|
67
|
+
help="run grype over the QBOM so CRA-ESR-03 and the VEX carry real findings (needs grype on PATH)")
|
|
68
|
+
p.add_argument("--grype-output", help="apply a pre-generated grype CycloneDX JSON instead of running grype")
|
|
69
|
+
p.add_argument("--equivalence", "-Q", action="store_true",
|
|
70
|
+
help="formally verify source-vs-transpiled equivalence (needs mqt.qcec: pip install 'quantbom[verify]')")
|
|
71
|
+
p.add_argument("--expect", action="append", metavar="OUTCOME=PROB",
|
|
72
|
+
help="declare an expected outcome probability (repeatable), e.g. --expect 00101011010=1.0")
|
|
73
|
+
p.add_argument("--tolerance", type=float, default=0.05,
|
|
74
|
+
help="max total-variation distance for --expect (default 0.05)")
|
|
75
|
+
p.add_argument("--expected-order", default="native", choices=["native", "qiskit"],
|
|
76
|
+
help="bit order of --expect outcomes: your framework's own (default) or Qiskit's measured order")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _source_kwargs(args) -> dict:
|
|
80
|
+
if args.entry:
|
|
81
|
+
from .harness import resolve_entry
|
|
82
|
+
|
|
83
|
+
return {"circuit": resolve_entry(args.entry)}
|
|
84
|
+
if args.qpy:
|
|
85
|
+
return {"qpy": args.qpy}
|
|
86
|
+
if args.qasm3:
|
|
87
|
+
return {"qasm3": args.qasm3}
|
|
88
|
+
if getattr(args, "bundle", None):
|
|
89
|
+
return {"bundle": args.bundle}
|
|
90
|
+
from . import _collect as qe
|
|
91
|
+
|
|
92
|
+
return {"circuit": qe.build_demo_circuit().to_qiskit()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collection_kwargs(args) -> dict:
|
|
96
|
+
return dict(backend=args.backend, environment=args.environment, syft_sbom=args.syft_sbom,
|
|
97
|
+
vuln_scan=args.vuln_scan, grype_output=args.grype_output, equivalence=args.equivalence,
|
|
98
|
+
expected={k: float(v) for k, v in (e.split("=", 1) for e in args.expect)} if args.expect else None,
|
|
99
|
+
tolerance=args.tolerance, expected_order=args.expected_order)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _print_actions(result: dict, framework: str | None) -> None:
|
|
103
|
+
mark = {"supported": "yes", "not_applicable": "n/a", "unsupported": "NO", "external": "external"}
|
|
104
|
+
for a in result["actions"]:
|
|
105
|
+
line = f"{a['id']:<30} {a['provider']:<5}"
|
|
106
|
+
if framework:
|
|
107
|
+
st = a["status"]
|
|
108
|
+
word = mark.get(st.get("status"), "?")
|
|
109
|
+
if st.get("conformance") == "pending" and word == "yes":
|
|
110
|
+
word = "yes?" # the adapter supports it; the conformance suite has not run on this framework
|
|
111
|
+
line += f" {word:<9}"
|
|
112
|
+
line += f" {'opt-in' if a.get('opt_in') else 'always':<7} {', '.join(a['controls']) or '—'}"
|
|
113
|
+
print(line)
|
|
114
|
+
if framework and a["status"].get("reason"):
|
|
115
|
+
print(f"{'':<32}why: {a['status']['reason']}")
|
|
116
|
+
reqs = "; ".join(f"{s}: {', '.join(ids)}" for s, ids in a["requirements"].items())
|
|
117
|
+
if reqs:
|
|
118
|
+
print(f"{'':<32}serves {reqs}")
|
|
119
|
+
how = a.get("how") or {}
|
|
120
|
+
for key in ("cli", "sdk", "install"):
|
|
121
|
+
if how.get(key):
|
|
122
|
+
print(f"{'':<32}{key}: {how[key]}")
|
|
123
|
+
for cmd in how.get("commands", []):
|
|
124
|
+
print(f"{'':<32}run: {cmd}")
|
|
125
|
+
if how.get("commands") and not how.get("cli"):
|
|
126
|
+
print(f"{'':<32} (MOD-08 repository tool — not part of the quantbom wheel)")
|
|
127
|
+
elif how.get("commands"):
|
|
128
|
+
print(f"{'':<32} (the same evidence, from the repository script)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> None:
|
|
132
|
+
parser = argparse.ArgumentParser(prog="quantbom", description="QuantBOM — certify a quantum release")
|
|
133
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
134
|
+
|
|
135
|
+
c = sub.add_parser("certify", help="collect evidence (or take a collected bundle) and certify a release")
|
|
136
|
+
_add_source(c, bundle=True)
|
|
137
|
+
_add_collection(c)
|
|
138
|
+
c.add_argument("--mode", default="report", choices=["report", "gate"])
|
|
139
|
+
c.add_argument("--privacy", default="standard", choices=["standard", "strict"],
|
|
140
|
+
help="strict: circuit-IR bytes (QPY/OpenQASM) never leave this machine; digests are client-attested")
|
|
141
|
+
c.add_argument("--assurance", default="standard", choices=["standard", "re-executed"],
|
|
142
|
+
help="re-executed: the service independently re-runs the circuit and must reproduce the claimed results (extra credit)")
|
|
143
|
+
c.add_argument("--scheme", default="eu-cra",
|
|
144
|
+
help="compliance scheme to certify under (default eu-cra; see GET /v1/schemes)")
|
|
145
|
+
c.add_argument("--retain", action="store_true",
|
|
146
|
+
help="opt into server-side retention of the QBOM document + results (never artifact bytes); manage via /v1/releases")
|
|
147
|
+
c.add_argument("--sign", action="store_true",
|
|
148
|
+
help="sign the evidence as its producer before upload (quantbom keygen first; needs quantbom[signing])")
|
|
149
|
+
c.add_argument("--key", metavar="FILE", help="producer signing key for --sign (default $QUANTBOM_SIGNING_KEY, then ~/.quantbom/signing.key)")
|
|
150
|
+
c.add_argument("--api-url")
|
|
151
|
+
c.add_argument("--api-key")
|
|
152
|
+
|
|
153
|
+
g = sub.add_parser("keygen", help="generate your producer signing key (Ed25519; private key written 0600)")
|
|
154
|
+
g.add_argument("--key-dir", metavar="DIR", help="where to write signing.key and signing.pub (default ~/.quantbom)")
|
|
155
|
+
g.add_argument("--force", action="store_true", help="replace an existing key (certificates already issued name the old key id)")
|
|
156
|
+
|
|
157
|
+
s_ = sub.add_parser("sign", help="sign a collected bundle as its producer (evidence/attestation.json + signing properties)")
|
|
158
|
+
s_.add_argument("--bundle", required=True, metavar="DIR", help="bundle from `quantbom collect`")
|
|
159
|
+
s_.add_argument("--key", metavar="FILE", help="producer signing key (default $QUANTBOM_SIGNING_KEY, then ~/.quantbom/signing.key)")
|
|
160
|
+
s_.add_argument("--strict", action="store_true",
|
|
161
|
+
help="the bundle will be uploaded with --privacy strict: leave the withheld circuit files out of the signature")
|
|
162
|
+
|
|
163
|
+
k = sub.add_parser("collect", help="collect the evidence bundle locally, without contacting the service")
|
|
164
|
+
_add_source(k, bundle=False)
|
|
165
|
+
_add_collection(k)
|
|
166
|
+
k.add_argument("--out", required=True, metavar="DIR", help="directory to write the bundle to")
|
|
167
|
+
|
|
168
|
+
v = sub.add_parser("verify", help="verify a certified release offline: digests, signatures and bindings re-derived from bytes "
|
|
169
|
+
"(the gate's controls are not re-run; that needs the gate)")
|
|
170
|
+
v.add_argument("path", help="the mirror directory (.quantbom/<release>/), a `quantbom collect` bundle, or a zip of either")
|
|
171
|
+
v.add_argument("--certificate", metavar="FILE", help="certificate.json, when not beside the QBOM")
|
|
172
|
+
anchor = v.add_mutually_exclusive_group()
|
|
173
|
+
anchor.add_argument("--keys", metavar="FILE", help="a /v1/keys document to trust (default: the one pinned by `quantbom keys --pin`)")
|
|
174
|
+
anchor.add_argument("--service-key", metavar="FILE", help="a single service public key (PEM) to trust")
|
|
175
|
+
v.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
176
|
+
|
|
177
|
+
at = sub.add_parser("attest", help="add partner / tool evidence to a collected bundle (MOD-05 tests, MOD-07 VEX + CVD policy, "
|
|
178
|
+
"MOD-09 constant-time proof, MOD-03/09 CBOM agility) — before `quantbom sign`")
|
|
179
|
+
ats = at.add_subparsers(dest="attest", required=True)
|
|
180
|
+
t_ = ats.add_parser("tests", help="MOD-05 security test report (control MOD05-TEST-01)")
|
|
181
|
+
t_.add_argument("--bundle", required=True, metavar="DIR")
|
|
182
|
+
t_.add_argument("--suite", required=True)
|
|
183
|
+
t_.add_argument("--outcome", required=True, choices=["passed", "failed"])
|
|
184
|
+
t_.add_argument("--passed", type=int, default=0)
|
|
185
|
+
t_.add_argument("--failed", type=int, default=0)
|
|
186
|
+
t_.add_argument("--skipped", type=int, default=0)
|
|
187
|
+
t_.add_argument("--tool", default="mod05", help="the test tool and version")
|
|
188
|
+
t_.add_argument("--source-module", default="MOD-05")
|
|
189
|
+
vu = ats.add_parser("vulns", help="VEX/VDR over the release plus a CVD-policy attestation (CRA-VULN-01, CRA-VDP-01)")
|
|
190
|
+
vu.add_argument("--bundle", required=True, metavar="DIR")
|
|
191
|
+
vu.add_argument("--policy-url", required=True, help="public coordinated-disclosure policy URL")
|
|
192
|
+
vu.add_argument("--contact", required=True, help="security contact (mailto: or URL)")
|
|
193
|
+
vu.add_argument("--sla-days", type=int, default=90)
|
|
194
|
+
vu.add_argument("--advisories", metavar="FILE", help='MOD-07 advisories ({"vulnerabilities": [...]}) merged into the release before the VEX is built')
|
|
195
|
+
ct = ats.add_parser("constant-time", help="constant-time proof from a dudect / ctgrind / Binsec-Rel result (CRA-ESR-06)")
|
|
196
|
+
ct.add_argument("--bundle", required=True, metavar="DIR")
|
|
197
|
+
ct.add_argument("--tool", required=True, choices=["binsec-rel", "ctgrind", "dudect"])
|
|
198
|
+
ct.add_argument("--target", required=True, help="analysed component (purl or symbol)")
|
|
199
|
+
src = ct.add_mutually_exclusive_group(required=True)
|
|
200
|
+
src.add_argument("--t-value", type=float, help="precomputed Welch t-statistic / tool t-value")
|
|
201
|
+
src.add_argument("--measurements", metavar="CSV", help="two-column CSV: class,nanoseconds (Welch's t is computed here)")
|
|
202
|
+
ct.add_argument("--threshold", type=float, default=10.0, help="|t| above this is leakage (dudect's 10)")
|
|
203
|
+
cr = ats.add_parser("crypto", help="CBOM ingestion and PQC migration readiness (CRA-CRYPTO-01)")
|
|
204
|
+
cr.add_argument("--bundle", required=True, metavar="DIR")
|
|
205
|
+
cr.add_argument("--cbom", metavar="FILE", help="CycloneDX CBOM; copied to crypto/cbom.cdx.json in the bundle (default: already there)")
|
|
206
|
+
|
|
207
|
+
ks = sub.add_parser("keys", help="show, or with --pin fetch, the service's certificate-signing keys (the trust anchor for `verify`)")
|
|
208
|
+
ks.add_argument("--pin", action="store_true", help="fetch /v1/keys from the service and pin it to ~/.quantbom/service-keys.json")
|
|
209
|
+
ks.add_argument("--api-url", help="service base URL (default $QUANTBOM_API_URL)")
|
|
210
|
+
ks.add_argument("--out", metavar="FILE", help="pin to this file instead")
|
|
211
|
+
|
|
212
|
+
a = sub.add_parser("actions", help="list harness actions: per framework, or those covering a requirement or control")
|
|
213
|
+
a.add_argument("--framework", help="qiskit, qrisp, cirq, braket, pennylane, cudaq — adds each action's status for it")
|
|
214
|
+
a.add_argument("--requirement", help="only actions whose evidence feeds this requirement (any scheme)")
|
|
215
|
+
a.add_argument("--control", help="only actions whose evidence feeds this control")
|
|
216
|
+
a.add_argument("--json", action="store_true", help="machine-readable output (same shape as GET /v1/harness)")
|
|
217
|
+
|
|
218
|
+
d = sub.add_parser("drift", help="semantic drift sentinel: compile a corpus in this toolchain and classify every change "
|
|
219
|
+
"(none / structural / observational / semantic) against the source and the baseline")
|
|
220
|
+
d.add_argument("--corpus", required=True, metavar="MODULE.py", help="a Python file defining PROGRAMS = {name: (framework, builder)}")
|
|
221
|
+
d.add_argument("--baseline", metavar="FILE", help="baseline JSON (default: <corpus>.baseline.json beside the corpus)")
|
|
222
|
+
d.add_argument("--update-baseline", action="store_true", help="record this run as the baseline (after reviewing the drift it reports)")
|
|
223
|
+
d.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
224
|
+
d.add_argument("--report", metavar="FILE", help="also write the report JSON here")
|
|
225
|
+
d.add_argument("--require-pins", metavar="FILE", help="refuse to run unless the installed toolchain equals these dist==version pins "
|
|
226
|
+
"(how a baseline is only ever recorded on the pinned toolchain)")
|
|
227
|
+
d.add_argument("--strict", action="store_true", help="on the pinned toolchain: fail on anything but none / nondeterministic "
|
|
228
|
+
"(the baseline no longer describes the toolchain it claims to)")
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
|
|
231
|
+
if args.cmd in ("keygen", "sign"):
|
|
232
|
+
from . import _sign
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
if args.cmd == "keygen":
|
|
236
|
+
out = _sign.keygen(args.key_dir, overwrite=args.force)
|
|
237
|
+
else:
|
|
238
|
+
out = _sign.sign(args.bundle, args.key, omit_refs=STRICT_WITHHELD_REFS if args.strict else ())
|
|
239
|
+
except (_sign.SigningUnavailable, FileNotFoundError, FileExistsError, ValueError) as err:
|
|
240
|
+
print(f"quantbom {args.cmd}: {err}", file=sys.stderr)
|
|
241
|
+
sys.exit(2)
|
|
242
|
+
print(json.dumps(out, indent=2))
|
|
243
|
+
sys.exit(0)
|
|
244
|
+
|
|
245
|
+
if args.cmd == "attest":
|
|
246
|
+
from . import attest
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if args.attest == "tests":
|
|
250
|
+
out = attest.tests(args.bundle, suite=args.suite, outcome=args.outcome, passed=args.passed, failed=args.failed,
|
|
251
|
+
skipped=args.skipped, tool=args.tool, source_module=args.source_module)
|
|
252
|
+
elif args.attest == "vulns":
|
|
253
|
+
out = attest.vulnerability_handling(args.bundle, policy_url=args.policy_url, contact=args.contact,
|
|
254
|
+
sla_days=args.sla_days, advisories=args.advisories)
|
|
255
|
+
elif args.attest == "constant-time":
|
|
256
|
+
out = attest.constant_time(args.bundle, tool=args.tool, target=args.target, t_value=args.t_value,
|
|
257
|
+
measurements=args.measurements, threshold=args.threshold)
|
|
258
|
+
else:
|
|
259
|
+
out = attest.crypto(args.bundle, cbom=args.cbom)
|
|
260
|
+
except (FileNotFoundError, ValueError, OSError) as err:
|
|
261
|
+
print(f"quantbom attest {args.attest}: {err}", file=sys.stderr)
|
|
262
|
+
sys.exit(2)
|
|
263
|
+
print(json.dumps(out, indent=2))
|
|
264
|
+
sys.exit(0)
|
|
265
|
+
|
|
266
|
+
if args.cmd == "verify":
|
|
267
|
+
from . import _verify
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
report = _verify.verify(args.path, certificate=args.certificate, keys_file=args.keys, service_key=args.service_key)
|
|
271
|
+
except (_verify.SigningUnavailable, FileNotFoundError, ValueError) as err:
|
|
272
|
+
print(f"quantbom verify: {err}", file=sys.stderr)
|
|
273
|
+
sys.exit(2)
|
|
274
|
+
print(json.dumps(report, indent=2) if args.json else _verify.render(report))
|
|
275
|
+
sys.exit(0 if report["verdict"] == "VERIFIED" else 1)
|
|
276
|
+
|
|
277
|
+
if args.cmd == "keys":
|
|
278
|
+
from . import _verify
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if args.pin:
|
|
282
|
+
from . import _config
|
|
283
|
+
|
|
284
|
+
out = _verify.pin_keys(args.api_url or _config["api_url"], args.out)
|
|
285
|
+
else:
|
|
286
|
+
path = Path(args.out) if args.out else _verify.PINNED_KEYS
|
|
287
|
+
if not path.is_file():
|
|
288
|
+
print(f"quantbom keys: nothing pinned at {path} — run `quantbom keys --pin`", file=sys.stderr)
|
|
289
|
+
sys.exit(2)
|
|
290
|
+
out = json.loads(path.read_text())
|
|
291
|
+
except (_verify.SigningUnavailable, ValueError, OSError) as err:
|
|
292
|
+
print(f"quantbom keys: {err}", file=sys.stderr)
|
|
293
|
+
sys.exit(2)
|
|
294
|
+
print(json.dumps(out, indent=2))
|
|
295
|
+
sys.exit(0)
|
|
296
|
+
|
|
297
|
+
if args.cmd == "drift":
|
|
298
|
+
from . import _drift
|
|
299
|
+
|
|
300
|
+
corpus = Path(args.corpus)
|
|
301
|
+
baseline_path = Path(args.baseline) if args.baseline else corpus.with_name(corpus.stem + ".baseline.json")
|
|
302
|
+
baseline = json.loads(baseline_path.read_text()) if baseline_path.exists() else None
|
|
303
|
+
try:
|
|
304
|
+
programs = _drift.load_corpus(corpus)
|
|
305
|
+
except (OSError, ValueError) as err:
|
|
306
|
+
print(f"quantbom drift: {err}", file=sys.stderr)
|
|
307
|
+
sys.exit(2)
|
|
308
|
+
if args.require_pins:
|
|
309
|
+
try:
|
|
310
|
+
mismatches = _drift.pin_mismatches(_drift.read_pins(args.require_pins))
|
|
311
|
+
except (OSError, ValueError) as err:
|
|
312
|
+
print(f"quantbom drift: {err}", file=sys.stderr)
|
|
313
|
+
sys.exit(2)
|
|
314
|
+
if mismatches:
|
|
315
|
+
print("quantbom drift: the installed toolchain is not the pinned one:\n " + "\n ".join(mismatches), file=sys.stderr)
|
|
316
|
+
sys.exit(2)
|
|
317
|
+
report, code = _drift.run(programs, baseline)
|
|
318
|
+
if args.strict:
|
|
319
|
+
bad = _drift.strict_violations(report)
|
|
320
|
+
if bad:
|
|
321
|
+
print("quantbom drift --strict: the baseline does not match this toolchain:\n " + "\n ".join(bad), file=sys.stderr)
|
|
322
|
+
code = 1
|
|
323
|
+
if args.report:
|
|
324
|
+
Path(args.report).write_text(json.dumps(report, indent=2))
|
|
325
|
+
if args.update_baseline:
|
|
326
|
+
baseline_path.write_text(json.dumps(_drift.baseline_from(report, args.require_pins), indent=2) + "\n")
|
|
327
|
+
print(f"baseline written: {baseline_path}", file=sys.stderr)
|
|
328
|
+
print(json.dumps(report, indent=2) if args.json else _drift.render(report))
|
|
329
|
+
if baseline is None and not args.update_baseline:
|
|
330
|
+
print("note: no baseline — drift vs baseline not assessed (run with --update-baseline to record one)", file=sys.stderr)
|
|
331
|
+
sys.exit(code)
|
|
332
|
+
|
|
333
|
+
if args.cmd == "actions":
|
|
334
|
+
from .harness import load_index, query
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
result = query(load_index(), framework=args.framework, requirement=args.requirement, control=args.control)
|
|
338
|
+
except KeyError as err:
|
|
339
|
+
print(f"quantbom actions: {err.args[0]}", file=sys.stderr)
|
|
340
|
+
sys.exit(2)
|
|
341
|
+
if args.json:
|
|
342
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
343
|
+
else:
|
|
344
|
+
print(f"harness {result['harness_version']}" + (f" — {json.dumps(result['query'])}" if result["query"] else ""))
|
|
345
|
+
record = args.framework and load_index()["frameworks"][args.framework].get("conformance")
|
|
346
|
+
if isinstance(record, dict):
|
|
347
|
+
if record.get("status") == "verified":
|
|
348
|
+
print(f"note: {args.framework} conformance verified — {record.get('versions')} · {record.get('host')} · {record.get('date')}")
|
|
349
|
+
else:
|
|
350
|
+
print(f"note: {args.framework} conformance PENDING — {record.get('note', '')}; "
|
|
351
|
+
"every 'yes' below is unverified until the suite has run")
|
|
352
|
+
elif record:
|
|
353
|
+
print(f"note: {args.framework} conformance {record}")
|
|
354
|
+
_print_actions(result, args.framework)
|
|
355
|
+
sys.exit(0)
|
|
356
|
+
|
|
357
|
+
if args.cmd == "collect":
|
|
358
|
+
try:
|
|
359
|
+
sbom = collect(release=args.release, out_dir=args.out, **_source_kwargs(args), **_collection_kwargs(args))
|
|
360
|
+
except ValueError as err: # a refused input (e.g. unbound parameters): say why, no traceback
|
|
361
|
+
print(f"quantbom collect: {err}", file=sys.stderr)
|
|
362
|
+
sys.exit(2)
|
|
363
|
+
print(json.dumps({"release_id": args.release, "bundle": str(sbom.parent), "qbom": str(sbom)}, indent=2))
|
|
364
|
+
sys.exit(0)
|
|
365
|
+
|
|
366
|
+
kw = dict(release=args.release, mode=args.mode, api_url=args.api_url, api_key=args.api_key,
|
|
367
|
+
privacy=args.privacy, retain=args.retain, assurance=args.assurance, scheme=args.scheme,
|
|
368
|
+
sign=args.sign, signing_key=args.key, **_collection_kwargs(args))
|
|
369
|
+
res = certify(**_source_kwargs(args), **kw)
|
|
370
|
+
|
|
371
|
+
keys = ("release_id", "overall_status", "assurance", "reexecution", "certificate", "verification", "credits_remaining", "retained", "mirror", "ok", "error")
|
|
372
|
+
print(json.dumps({k: res.get(k) for k in keys if k in res}, indent=2))
|
|
373
|
+
ok = res.get("ok") and (args.mode != "gate" or res.get("overall_status") == "COMPLIANT")
|
|
374
|
+
sys.exit(0 if ok else 1)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Framework adapter registry (HARNESS_PLAN.md §2.1).
|
|
2
|
+
|
|
3
|
+
Order matters for detection: Qiskit's isinstance check first, then Qrisp's
|
|
4
|
+
duck-typed `.to_qiskit()`, then frameworks recognised by their module.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .base import FrameworkAdapter, Undecidable
|
|
11
|
+
from .braket import BraketAdapter
|
|
12
|
+
from .cirq import CirqAdapter
|
|
13
|
+
from .cudaq import CudaqAdapter
|
|
14
|
+
from .pennylane import PennyLaneAdapter
|
|
15
|
+
from .qiskit import QiskitAdapter
|
|
16
|
+
from .qrisp import QrispAdapter
|
|
17
|
+
|
|
18
|
+
ADAPTERS: tuple[FrameworkAdapter, ...] = (
|
|
19
|
+
QiskitAdapter(), QrispAdapter(), CirqAdapter(), BraketAdapter(), PennyLaneAdapter(), CudaqAdapter(),
|
|
20
|
+
)
|
|
21
|
+
_BY_NAME = {a.name: a for a in ADAPTERS}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get(name: str) -> FrameworkAdapter | None:
|
|
25
|
+
return _BY_NAME.get(name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# K7: computational models the harness does not certify, recognised by the
|
|
29
|
+
# module an object comes from, so the refusal names the model rather than
|
|
30
|
+
# listing what was expected. The gate (22 controls, QBOM v0.3) is defined for
|
|
31
|
+
# gate-model circuits; these need their own evidence model, not a translation.
|
|
32
|
+
NON_GATE_MODELS = {
|
|
33
|
+
"annealing (Ising / QUBO)": ("dimod", "dwave", "dwavebinarycsp", "neal", "dwave_networkx", "qubovert", "pyqubo"),
|
|
34
|
+
"photonic (continuous-variable / linear optics)": ("strawberryfields", "perceval", "piquasso", "thewalrus"),
|
|
35
|
+
"analog / pulse-level": ("qutip", "pulser", "bloqade", "qiskit_dynamics", "qiskit.pulse"),
|
|
36
|
+
"annealing-style schedule (Braket AHS)": ("braket.ahs",),
|
|
37
|
+
}
|
|
38
|
+
GATE_MODEL_INPUTS = ("qiskit.QuantumCircuit, a Qrisp circuit/session (.to_qiskit()), a cirq.Circuit, "
|
|
39
|
+
"a braket Circuit, a PennyLane QNode/tape, or a CUDA-Q kernel")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def model_of(obj: Any) -> str | None:
|
|
43
|
+
"""The non-gate model an object belongs to, by its type's module; None when unknown."""
|
|
44
|
+
module = type(obj).__module__ or ""
|
|
45
|
+
for model, roots in NON_GATE_MODELS.items():
|
|
46
|
+
if any(module == r or module.startswith(r + ".") for r in roots):
|
|
47
|
+
return model
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect(obj: Any) -> FrameworkAdapter:
|
|
52
|
+
for adapter in ADAPTERS:
|
|
53
|
+
if adapter.detect(obj):
|
|
54
|
+
return adapter
|
|
55
|
+
model = model_of(obj)
|
|
56
|
+
if model:
|
|
57
|
+
raise TypeError(
|
|
58
|
+
f"{type(obj).__module__}.{type(obj).__name__} is an {model} program. QuantBOM certifies gate-model circuits "
|
|
59
|
+
f"({GATE_MODEL_INPUTS}): the 22 controls and the QBOM profile are defined for gates, measurements and "
|
|
60
|
+
"transpilation, so this program cannot be translated into that evidence; it is refused, not approximated"
|
|
61
|
+
)
|
|
62
|
+
raise TypeError(
|
|
63
|
+
f"certify() expects a gate-model circuit — {GATE_MODEL_INPUTS} — got {type(obj).__module__}.{type(obj).__name__}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["ADAPTERS", "FrameworkAdapter", "NON_GATE_MODELS", "Undecidable", "detect", "get", "model_of"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Framework adapter contract (HARNESS_PLAN.md §2.1).
|
|
2
|
+
|
|
3
|
+
One adapter per quantum programming framework. Everything framework-specific
|
|
4
|
+
in MOD-08 lives behind this interface: lowering to the normalized IR, the
|
|
5
|
+
reference simulator QNT-06 compares against, the qubit/bit order, and the
|
|
6
|
+
component the framework contributes to the inventory. Adding a framework means
|
|
7
|
+
writing one adapter and passing tests/frameworks/test_conformance.py — nothing
|
|
8
|
+
else in MOD-08 changes.
|
|
9
|
+
|
|
10
|
+
Import rule: adapter modules import only the standard library at load time and
|
|
11
|
+
pull their framework in lazily, inside methods. The harness contract check
|
|
12
|
+
reads adapter facts on a bare Python with no quantum packages installed.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Undecidable(Exception):
|
|
20
|
+
"""A comparison that cannot be decided — reported as `inconclusive` (N-A)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dist_version(dist: str) -> str:
|
|
24
|
+
try:
|
|
25
|
+
from importlib.metadata import version
|
|
26
|
+
|
|
27
|
+
return version(dist)
|
|
28
|
+
except Exception:
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def qiskit_version() -> str:
|
|
33
|
+
from qiskit import __version__
|
|
34
|
+
|
|
35
|
+
return __version__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FrameworkAdapter:
|
|
39
|
+
#: source-format value recorded in the QBOM, and the manifest key
|
|
40
|
+
name: str = ""
|
|
41
|
+
#: PyPI distribution recorded as the framework's SBOM component
|
|
42
|
+
component: str = ""
|
|
43
|
+
#: SPDX licence of that component
|
|
44
|
+
license: str = ""
|
|
45
|
+
#: "qubit0_first": qubit 0 is the leftmost character of the framework's own
|
|
46
|
+
#: bitstrings; "qubit0_last": the rightmost (Qiskit's measured order);
|
|
47
|
+
#: "by-input": the reading differs by input kind — see `bit_orders`
|
|
48
|
+
bit_order: str = "qubit0_last"
|
|
49
|
+
#: input kind -> bit order, for a framework whose own readings differ by
|
|
50
|
+
#: input (Qrisp: QuantumCircuit.run() vs QuantumVariable.get_measurement()).
|
|
51
|
+
#: None when `bit_order` applies to every input.
|
|
52
|
+
bit_orders: dict[str, str] | None = None
|
|
53
|
+
#: None (input already is the IR), "qrisp-compiler" (the framework's own
|
|
54
|
+
#: compiler lowers it), or the OpenQASM dialect an adapter translates through
|
|
55
|
+
translation: str | None = None
|
|
56
|
+
#: True when the framework's dynamic constructs (mid-circuit measurement,
|
|
57
|
+
#: classical control) survive lowering and are conformance-tested (C7).
|
|
58
|
+
#: Enforced at collection: a dynamic program from an adapter that does not
|
|
59
|
+
#: claim it is refused, never certified on an unverified lowering.
|
|
60
|
+
supports_dynamic: bool = False
|
|
61
|
+
#: What QNT-06 compares the lowered circuit against: "dense" (the
|
|
62
|
+
#: framework's own simulator yields a dense unitary, capped by
|
|
63
|
+
#: _fidelity.max_qubits()), "stabilizer" (planned, K3) or "none" (no
|
|
64
|
+
#: adapter-side translation to check, or no reference available)
|
|
65
|
+
reference: str = "none"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def translates(self) -> bool:
|
|
69
|
+
"""True when an adapter-side translation exists that QNT-06 must check."""
|
|
70
|
+
return self.translation not in (None, "qrisp-compiler")
|
|
71
|
+
|
|
72
|
+
def detect(self, obj: Any) -> bool:
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
# ---- conventions, pinned per input (never "whichever matches") ----
|
|
76
|
+
def input_kind(self, obj: Any) -> str | None:
|
|
77
|
+
"""The kind of input this object is, for frameworks with `bit_orders`."""
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def bit_order_for(self, obj: Any) -> str:
|
|
81
|
+
"""How the framework's OWN reading of this object's outcomes is ordered."""
|
|
82
|
+
if self.bit_orders:
|
|
83
|
+
kind = self.input_kind(obj)
|
|
84
|
+
if kind not in self.bit_orders:
|
|
85
|
+
raise Undecidable(f"{self.name}: no declared bit order for input kind {kind!r}")
|
|
86
|
+
return self.bit_orders[kind]
|
|
87
|
+
return self.bit_order
|
|
88
|
+
|
|
89
|
+
def measured_order_for(self, obj: Any) -> str:
|
|
90
|
+
"""How the LOWERED circuit's measured string is ordered: Qiskit's
|
|
91
|
+
(`qubit0_last`) unless the lowering allocates classical bits in reverse."""
|
|
92
|
+
return "qubit0_last"
|
|
93
|
+
|
|
94
|
+
def open_parameters(self, obj: Any) -> list[str]:
|
|
95
|
+
"""Names of parameters the object leaves unbound — checked BEFORE lowering,
|
|
96
|
+
so an open circuit is refused with one clear message rather than whatever
|
|
97
|
+
the framework's exporter raises. Default: none known at this level (the
|
|
98
|
+
lowered Qiskit circuit is checked again in any case)."""
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
def dynamic_features(self, obj: Any) -> list[str]:
|
|
102
|
+
"""Dynamic constructs visible BEFORE lowering (mid-circuit measurement,
|
|
103
|
+
classical control), so an adapter that does not support them refuses
|
|
104
|
+
with one message instead of a library error. Default: none known at
|
|
105
|
+
this level; the lowered circuit is inspected again in any case."""
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
def lower(self, obj: Any) -> tuple[Any, str, Any]:
|
|
109
|
+
"""(qiskit_circuit, frontend_label, native_object_kept_for_evidence_or_None)."""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
# ---- QNT-06 hooks — required only when `translates` ----
|
|
113
|
+
def native_export(self, obj: Any) -> tuple[str, str]:
|
|
114
|
+
"""The exact text the translation consumed, and its format."""
|
|
115
|
+
raise Undecidable(f"no native export for {self.name}")
|
|
116
|
+
|
|
117
|
+
def reference_unitary(self, obj: Any) -> tuple[Any, str]:
|
|
118
|
+
"""(unitary from the framework's OWN simulator, reference description)."""
|
|
119
|
+
raise Undecidable(f"no reference simulator for {self.name}")
|
|
120
|
+
|
|
121
|
+
def reference_state(self, obj: Any) -> tuple[Any, str]:
|
|
122
|
+
"""(final state on |0...0> from the framework's OWN simulator, description) —
|
|
123
|
+
the scalable reference beyond the dense cap (K3); qubit 0 most significant."""
|
|
124
|
+
raise Undecidable(f"no reference state simulator for {self.name}")
|
|
125
|
+
|
|
126
|
+
def export_for_equivalence(self, obj: Any) -> tuple[str, str, str | None]:
|
|
127
|
+
"""(text, format, note): the native export as an independent equivalence
|
|
128
|
+
checker can parse it. Default: the native export itself; an adapter whose
|
|
129
|
+
export uses non-standard gate names rewrites them and says so."""
|
|
130
|
+
text, fmt = self.native_export(obj)
|
|
131
|
+
return text, fmt, None
|
|
132
|
+
|
|
133
|
+
# ---- conformance hook: the framework's own reading of measurements ----
|
|
134
|
+
def native_sample(self, obj: Any, shots: int = 8) -> dict[str, int]:
|
|
135
|
+
raise NotImplementedError(f"{self.name} adapter has no native sampler")
|
|
136
|
+
|
|
137
|
+
# ---- QNT-09 hook: the program run on the framework's own simulator ----
|
|
138
|
+
def native_run(self, obj: Any, shots: int) -> dict[str, Any]:
|
|
139
|
+
"""Sample the NATIVE program on the framework's own simulator.
|
|
140
|
+
|
|
141
|
+
Returns {"simulator": description, "reading": counts as the framework
|
|
142
|
+
reads them, "reading_order": "qubit0_first" | "measured",
|
|
143
|
+
"measured": the same counts keyed as the shared-format run's measured
|
|
144
|
+
string (spaces removed)}; Qrisp sessions add "by_variable". A program
|
|
145
|
+
without measurements is measured on every qubit, as the collector
|
|
146
|
+
measures the lowered circuit. Undecidable when the framework cannot
|
|
147
|
+
say, or the reading cannot be mapped onto the measured string."""
|
|
148
|
+
raise Undecidable(f"no native simulator run for {self.name}")
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _reversed_reading(reading: dict[str, int], simulator: str) -> dict[str, Any]:
|
|
152
|
+
"""For frameworks that print qubit 0 first: the measured string is the
|
|
153
|
+
reading reversed (registers concatenated in declaration order)."""
|
|
154
|
+
return {"simulator": simulator, "reading": reading, "reading_order": "qubit0_first",
|
|
155
|
+
"measured": {k[::-1]: int(v) for k, v in reading.items()}}
|