From 1f544f1a56124149c8740474be40746646f4be92 Mon Sep 17 00:00:00 2001 From: Lukas Burgholzer Date: Mon, 24 Aug 2026 16:41:32 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20an=20optional=20packaged=20QD?= =?UTF-8?q?MI=20Driver=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GPT-5.6 Sol via Codex --- .../plans/qdmi-default-driver-extension-c2.md | 52 ++++ CHANGELOG.md | 4 + UPGRADING.md | 13 +- bindings/qdmi/qdmi.cpp | 102 ++++++- docs/qdmi/configuration.md | 53 +++- docs/qdmi/driver.md | 39 +++ include/mqt-core/qdmi/Client.hpp | 18 ++ include/mqt-core/qdmi/driver/Driver.hpp | 22 +- python/mqt/core/_qdmi_discovery.py | 77 +++++ python/mqt/core/qdmi/__init__.pyi | 3 +- python/mqt/core/qdmi/default_driver.pyi | 36 +++ src/qdmi/Client.cpp | 194 +++++++++++- src/qdmi/driver/DeviceRegistry.cpp | 238 +++++++++++---- src/qdmi/driver/DeviceRegistry.hpp | 16 + src/qdmi/driver/Driver.cpp | 282 ++++++++++++++---- test/python/qdmi/test_default_driver.py | 77 +++++ test/python/qdmi/test_discovery.py | 126 ++++++++ test/qdmi/CMakeLists.txt | 165 +++++++++- test/qdmi/client_runtime_driver.cpp | 24 ++ test/qdmi/driver/session_device.cpp | 52 +++- test/qdmi/driver/test_driver.cpp | 49 ++- test/qdmi/registry/test_device_registry.cpp | 38 ++- test/qdmi/test_client_runtime.cpp | 51 ++++ test/qdmi/test_client_targeted_selection.cpp | 42 +++ test/qdmi/test_default_driver_extension.cpp | 173 +++++++++++ test/qdmi/test_packaged_runtime.cpp | 7 + 26 files changed, 1807 insertions(+), 146 deletions(-) create mode 100644 .agent/plans/qdmi-default-driver-extension-c2.md create mode 100644 python/mqt/core/_qdmi_discovery.py create mode 100644 python/mqt/core/qdmi/default_driver.pyi create mode 100644 test/python/qdmi/test_default_driver.py create mode 100644 test/python/qdmi/test_discovery.py create mode 100644 test/qdmi/test_client_targeted_selection.cpp create mode 100644 test/qdmi/test_default_driver_extension.cpp diff --git a/.agent/plans/qdmi-default-driver-extension-c2.md b/.agent/plans/qdmi-default-driver-extension-c2.md new file mode 100644 index 0000000000..3135b616f4 --- /dev/null +++ b/.agent/plans/qdmi-default-driver-extension-c2.md @@ -0,0 +1,52 @@ +# Optional packaged QDMI driver extension + +Status: independently rebased and validated locally. + +## Motivation and settled boundary + +Installed providers need catalogue discovery and targeted stable-ID opening +without importing vendor Python code or copying libraries beside the driver. +Core's packaged driver provides an optional private extension for this purpose. +The standard Client ABI remains usable with drivers that lack that extension. +Standardizing discovery/configuration is QDMI v2 work. + +This is Core #2230 on #2229, targeting Core 4.1 / QDMI 1.4. It does not depend +on metadata removal, batching, payload capabilities, or compiler changes. + +## Implementation + +- Load the two optional private symbols in the Client wrapper. Staging a + manifest does not select the process-wide driver; successful raw targeted + allocation does, even when subsequent initialization fails. +- Stage trusted manifests transactionally at lowest precedence, freeze the + registry after successful construction, and keep canonical paths idempotent. +- Open exactly one stable ID with strict per-call overrides. Preserve sized + custom values, reject malformed paths/IDs, and propagate provider errors. +- Discover Python manifests using entry-point metadata and wheel RECORD paths, + never provider imports. Invalid automatic entries warn and are skipped; + explicit staging remains strict. +- Retain initialized provider libraries across independent sessions. Complete + initialization before moving the session owner, including on Windows. + +The entry points are qdmi::default_driver::addManifest/openDevice and the Python +default_driver submodule. No public QDMI C header changes are needed. +Installed-consumer packaging is the separate follow-up #2231. + +## Acceptance + +Run the independent release build and CTest suite, generated stubs, QDMI/SDK +Python tests, repository lint, and C++ lint. Check absent optional symbols, +selection timing, freeze rollback, idempotent paths, strict overrides, valid +warning outputs, malformed JSON, UTF-8 paths, and session lifetime. Discovery +tests must reject missing RECORD, ambiguous/off-anchor paths and traversal while +proving that provider code is not imported. Retain current optional-device +configurations, concurrency, and compiler rules. + +The release suite passed 3,873 tests with one existing skip. All 467 selected +Python tests passed. Stub generation, repository lint and C++ lint passed. + +## Recovery and non-goals + +Keep useful commits, human attribution and review threads. Use guarded pushes; +do not create archive branches or request reviews. There is no new registry API, +public discovery standard, scheduler policy, or payload contract here. diff --git a/CHANGELOG.md b/CHANGELOG.md index b4bcb1359f..73921a5f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,9 @@ releases may include breaking changes. - 💥 Replace the QDMI-specific primitives with native Qiskit primitives and typed backend factories. Sampler and `memory=True` require genuine QDMI `SHOTS` ([#2358]) ([**@burgholzer**]) +- ✨ Discover installed device catalogues without importing provider modules and + open targeted sessions through the packaged driver's optional private + configuration extension ([#2230]) ([**@burgholzer**]) - 💥 Load one replaceable QDMI 1.4 Client driver through a validated function table, split `MQT::CoreQDMI` from the packaged `MQT::CoreQDMIDriver`, and use stable Client device IDs across C++, Python, MLIR, Qiskit, PennyLane, and @@ -911,6 +914,7 @@ for previous changelogs._ [#2257]: https://github.com/munich-quantum-toolkit/core/pull/2257 [#2246]: https://github.com/munich-quantum-toolkit/core/pull/2246 [#2240]: https://github.com/munich-quantum-toolkit/core/pull/2240 +[#2230]: https://github.com/munich-quantum-toolkit/core/pull/2230 [#2229]: https://github.com/munich-quantum-toolkit/core/pull/2229 [#2232]: https://github.com/munich-quantum-toolkit/core/pull/2232 [#2228]: https://github.com/munich-quantum-toolkit/core/pull/2228 diff --git a/UPGRADING.md b/UPGRADING.md index 8194107c89..b40fe40422 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -31,8 +31,17 @@ The generic session parameters are `token`, `auth_file`, `auth_url`, `username`, `password`, `project_id`, and `custom1` through `custom5`. The selected Driver owns their validation and meaning. The former Python `base_url`, `device_config`, and `device_config_file` keywords were tied to MQT Core's -Driver and are not part of the generic Client boundary. Use persistent Driver -configuration when that Driver supports these settings. +Driver and are not part of the generic Client boundary. Code that deliberately +requires MQT Core's packaged Driver can use +`mqt.core.qdmi.default_driver.open_device` for these settings. Otherwise, use +persistent Driver configuration. + +The `mqt.core.qdmi.default_driver` submodule is an optional MQT Core extension, +not a standard QDMI Client API. Its `add_manifest` function stages trusted +package manifests, and its `open_device` function opens one configured stable ID +without enumerating other devices. Generic `ClientSession` and `open_device` +calls never require or use this extension. Third-party Client drivers may omit +it. The MLIR `from_device_id` helpers and the Qiskit and PennyLane adapters accept the same generic session parameters. Device, site, operation, and job wrappers diff --git a/bindings/qdmi/qdmi.cpp b/bindings/qdmi/qdmi.cpp index 08fdb93ad2..4a77181166 100644 --- a/bindings/qdmi/qdmi.cpp +++ b/bindings/qdmi/qdmi.cpp @@ -9,6 +9,7 @@ */ #include "qdmi/Client.hpp" +#include "qdmi/common/Common.hpp" #include #include @@ -66,6 +67,61 @@ qdmi::SessionConfig makeClientSessionConfig( }; } +[[nodiscard]] auto makeDeviceSessionJson( + const std::optional& baseUrl, + const std::optional& token, + const std::optional& authFile, + const std::optional& authUrl, + const std::optional& username, + const std::optional& password, + const std::optional& deviceConfig, + const std::optional& deviceConfigFile, + const std::optional& custom1, + const std::optional& custom2, + const std::optional& custom3, + const std::optional& custom4, + const std::optional& custom5) -> std::string { + if (deviceConfig && deviceConfigFile) { + throw nb::value_error( + "device_config and device_config_file are mutually exclusive"); + } + nb::dict session; + const auto setString = [&session](const char* key, + const std::optional& value) { + if (value) { + session[key] = *value; + } + }; + setString("base-url", baseUrl); + setString("token", token); + if (authFile) { + session["auth-file"] = qdmi::detail::pathToUtf8(*authFile); + } + setString("auth-url", authUrl); + setString("username", username); + setString("password", password); + setString("custom1", custom1); + setString("custom2", custom2); + setString("custom3", custom3); + setString("custom4", custom4); + setString("custom5", custom5); + if (deviceConfig) { + nb::dict source; + source["inline"] = + nb::module_::import_("json").attr("loads")(*deviceConfig); + session["device-config"] = std::move(source); + } else if (deviceConfigFile) { + nb::dict source; + source["file"] = qdmi::detail::pathToUtf8(*deviceConfigFile); + session["device-config"] = std::move(source); + } + if (session.empty()) { + return {}; + } + return nb::cast( + nb::module_::import_("json").attr("dumps")(session)); +} + template [[nodiscard]] nb::object queryCustomValue(Query query, const nb::handle valueType) { @@ -105,7 +161,9 @@ template } // namespace NB_MODULE(MQT_CORE_MODULE_NAME, qdmiModule) { - qdmiModule.doc() = "QDMI Client entities."; + qdmiModule.doc() = "QDMI Client entities and MQT Core's default driver."; + auto defaultDriver = qdmiModule.def_submodule( + "default_driver", "Configure MQT Core's packaged QDMI Client driver."); bindings::registerSlurm(qdmiModule); nb::class_(qdmiModule, "ClientSession", @@ -707,6 +765,48 @@ when the custom slot is unsupported.)pb"); nb::sig("def __eq__(self, arg: object, /) -> bool")); operation.def(nb::self != nb::self, nb::sig("def __ne__(self, arg: object, /) -> bool")); + + defaultDriver.def("add_manifest", &qdmi::default_driver::addManifest, + "manifest_path"_a, + "Stage one installed package manifest before the default " + "driver freezes."); + + defaultDriver.def( + "open_device", + [](const std::string& deviceId, + const std::optional& driverPath, + const std::optional& baseUrl, + const std::optional& token, + const std::optional& authFile, + const std::optional& authUrl, + const std::optional& username, + const std::optional& password, + const std::optional& deviceConfig, + const std::optional& deviceConfigFile, + const std::optional& custom1, + const std::optional& custom2, + const std::optional& custom3, + const std::optional& custom4, + const std::optional& custom5) { + return qdmi::default_driver::openDevice( + deviceId, + makeDeviceSessionJson(baseUrl, token, authFile, authUrl, username, + password, deviceConfig, deviceConfigFile, + custom1, custom2, custom3, custom4, custom5), + driverPath); + }, + "device_id"_a, nb::kw_only(), "driver_path"_a = std::nullopt, + "base_url"_a = std::nullopt, "token"_a = std::nullopt, + "auth_file"_a = std::nullopt, "auth_url"_a = std::nullopt, + "username"_a = std::nullopt, "password"_a = std::nullopt, + "device_config"_a = std::nullopt, "device_config_file"_a = std::nullopt, + "custom1"_a = std::nullopt, "custom2"_a = std::nullopt, + "custom3"_a = std::nullopt, "custom4"_a = std::nullopt, + "custom5"_a = std::nullopt, + "Open one device through MQT Core's strict private driver extension."); + + nb::module_::import_("mqt.core._qdmi_discovery") + .attr("discover_qdmi_manifests")(defaultDriver.attr("add_manifest")); } } // namespace mqt diff --git a/docs/qdmi/configuration.md b/docs/qdmi/configuration.md index 1eb711e75d..a3d32c830c 100644 --- a/docs/qdmi/configuration.md +++ b/docs/qdmi/configuration.md @@ -81,7 +81,8 @@ included in Driver warnings. Definitions are merged field by field by ID, from lowest to highest precedence: -1. generated `*.qdmi.json` fragments packaged beside the MQT Core Driver; +1. generated `*.qdmi.json` fragments packaged beside the MQT Core Driver and + trusted manifests staged by installed packages; 2. the system `qdmi.json`; 3. the user or XDG `qdmi.json`; 4. the nearest project `qdmi.json`; @@ -101,6 +102,50 @@ a device that an administrator disabled. `MQT_CORE_QDMI_CONFIG_FILE` replaces the system, user, and project levels while retaining packaged built-ins. +## Installed Python package manifests + +A Python distribution can advertise one trusted device manifest without +importing its provider package. Add an entry point to the distribution's +`pyproject.toml`: + +```toml +[project.entry-points."mqt.core.qdmi.manifests"] +"example.qdmi.json" = "vendor.device" +``` + +The entry-point name must be the exact, path-free basename of one `*.qdmi.json` +file. The value must be a dotted Python module name that anchors the owning +package. The distribution's wheel `RECORD` must contain exactly one file with +that basename below the corresponding module path. In this example, the path +starts with `vendor/device/`. MQT Core resolves the file through the +distribution metadata. It does not load the entry point or import the provider +module. + +Importing {py:mod}`mqt.core.qdmi` stages every valid advertised manifest in the +packaged Driver's lowest-precedence layer. Invalid or ambiguous automatic +entries cause one `RuntimeWarning` each and are skipped. A metadata enumeration +failure causes one warning and skips automatic discovery. Applications can stage +a known manifest explicitly when an error must stop startup: + +```python +from pathlib import Path + +from mqt.core.qdmi import default_driver + +default_driver.add_manifest(Path("vendor/device/example.qdmi.json")) +``` + +Explicit staging reports malformed manifests, missing libraries, and conflicting +device IDs as errors. Staging the same canonical path more than once is +idempotent, including after the packaged Driver freezes its registry. A new path +cannot be staged after the packaged Driver successfully constructs and freezes +its registry during a session-allocation request. A failed Driver construction +rolls the freeze back so startup can be retried. Staging loads the packaged +Driver library but does not select it as the process's generic Client driver. +Package staging and default targeted opens ignore `MQT_CORE_QDMI_DRIVER` and use +the packaged Driver. An explicit targeted `driver_path` overrides that default. +Standard Client sessions continue to honor the environment override. + ## Using configured devices When the packaged QDMI Driver initializes a Client session, it opens the @@ -180,9 +225,9 @@ Built-in targets generate manifests beside their runtime libraries in both build and install trees. Library paths in those fragments contain only the target filename, so moving an installed tree or Python wheel preserves discovery. Automatic discovery searches relative to the MQT Core Driver, not every library -loaded by the process. An application using a separately installed device -implementation therefore copies its manifest beside the Driver or registers its -definition by stable ID. +loaded by the process. A separately installed Python distribution can use the +`mqt.core.qdmi.manifests` entry point described above. Other applications copy +the manifest beside the Driver or register the definition by stable ID. A fully static executable has no portable shared-module location. Place the fragments beside the executable, point `MQT_CORE_QDMI_CONFIG_FILE` at a complete diff --git a/docs/qdmi/driver.md b/docs/qdmi/driver.md index fa99bfb4b3..45bcc8c169 100644 --- a/docs/qdmi/driver.md +++ b/docs/qdmi/driver.md @@ -36,6 +36,45 @@ different Driver fails. A failed load, ABI check, symbol check, or raw-session allocation does not select a Driver, so a later call can retry. MQT Core keeps the selected shared library loaded while its function pointers can be used. +## Optional Packaged-Driver Extension + +MQT Core's packaged Driver adds two private symbols to the same shared library +that exports the standard Client interface: + +- `MQT_CORE_QDMI_driver_add_manifest_v1` stages a trusted package manifest. +- `MQT_CORE_QDMI_driver_session_alloc_for_device_v1` allocates a session for one + configured stable ID. + +These symbols are an MQT Core extension. They are not part of a public QDMI +header, and another Client driver can omit them. MQT Core resolves them as +optional symbols and calls them only through the extension API. Missing +extension symbols do not prevent standard Client sessions. Generic +{cpp-api:func}`qdmi::Session::openDevice` and Python +{py:func}`mqt.core.qdmi.open_device` enumerate the standard Client device list +and never use the private targeted-session symbol. + +Use {cpp-api:func}`qdmi::default_driver::addManifest` or Python +{py:func}`mqt.core.qdmi.default_driver.add_manifest` before the packaged Driver +freezes its registry. Staging the packaged library does not select it as the +generic Client driver. The first successful raw standard or targeted session +allocation selects a Client driver. A later targeted-session initialization or +device query failure does not undo that selection. + +By default, the `default_driver` extension resolves MQT Core's packaged Driver +and ignores `MQT_CORE_QDMI_DRIVER`. An explicit `driver_path` overrides that +default for a compatible extension. The process selection rule still prevents +switching Drivers after a successful raw allocation. Standard Client sessions +use the selection order above, including the environment override. + +Use {cpp-api:func}`qdmi::default_driver::openDevice` or Python +{py:func}`mqt.core.qdmi.default_driver.open_device` when an application +deliberately depends on the packaged Driver. The targeted call merges manifest +defaults with the supplied JSON or Python overrides. It rejects unsupported +parameters and malformed configuration, propagates device-library status codes, +and requires the session to expose exactly one device. Each call creates an +independent session. The returned device and its derived wrappers retain that +session until the last wrapper is destroyed. + ## Building the Bundled Devices Standalone MQT Core builds include the DDSIM and superconducting QDMI device diff --git a/include/mqt-core/qdmi/Client.hpp b/include/mqt-core/qdmi/Client.hpp index 07eb57e79f..6633b8f6d1 100644 --- a/include/mqt-core/qdmi/Client.hpp +++ b/include/mqt-core/qdmi/Client.hpp @@ -494,6 +494,20 @@ class Site; class Device; class Operation; +namespace default_driver { +/// Stage one package manifest in MQT Core's optional driver extension. +void addManifest(const std::filesystem::path& path); + +/// Open one default-driver device with strict merged session configuration. +/// @param id Stable device ID. +/// @param deviceSessionJson JSON session overrides. +/// @param driverPath Optional compatible extension path. By default, this call +/// uses MQT Core's packaged Driver and ignores `MQT_CORE_QDMI_DRIVER`. +[[nodiscard]] Device openDevice( + std::string_view id, std::string_view deviceSessionJson = {}, + const std::optional& driverPath = std::nullopt); +} // namespace default_driver + /** * @brief Class representing the Session library. * @details This class provides methods to query available devices and @@ -804,6 +818,10 @@ class Device { Device(QDMI_Device device, std::shared_ptr session) : device_(device), session_(std::move(session)) {} + friend Device + default_driver::openDevice(std::string_view, std::string_view, + const std::optional&); + /// Wrap operation handles while retaining their owning device session. [[nodiscard]] std::vector wrapOperations(std::span operations) const; diff --git a/include/mqt-core/qdmi/driver/Driver.hpp b/include/mqt-core/qdmi/driver/Driver.hpp index 78d1a9580b..95a0adfc04 100644 --- a/include/mqt-core/qdmi/driver/Driver.hpp +++ b/include/mqt-core/qdmi/driver/Driver.hpp @@ -238,9 +238,9 @@ struct QDMI_Device_impl_d { */ explicit QDMI_Device_impl_d(std::unique_ptr&& lib, const qdmi::DeviceSessionConfig& config = {}, - std::string id = {}) + std::string id = {}, const bool strict = false) : QDMI_Device_impl_d(std::shared_ptr(std::move(lib)), config, - std::move(id)) {} + std::move(id), nullptr, strict) {} /** * @brief Constructor for the QDMI device. @@ -255,7 +255,8 @@ struct QDMI_Device_impl_d { explicit QDMI_Device_impl_d(std::shared_ptr lib, const qdmi::DeviceSessionConfig& config = {}, std::string id = {}, - QDMI_Child_Device childDevice = nullptr); + QDMI_Child_Device childDevice = nullptr, + bool strict = false); /** * @brief Destructor for the QDMI device. @@ -411,6 +412,9 @@ struct QDMI_Session_impl_d { /// @brief Snapshot of devices visible when this session was allocated. std::vector devices_; + /// Owns the device created by a targeted private allocation. + std::shared_ptr ownedDevice_; + public: /// @brief Constructor for the QDMI session. explicit QDMI_Session_impl_d( @@ -419,6 +423,9 @@ struct QDMI_Session_impl_d { /// @brief Constructor from an explicit device-handle snapshot. explicit QDMI_Session_impl_d(const std::vector& devices); + /// @brief Constructor for one privately targeted device session. + explicit QDMI_Session_impl_d(std::shared_ptr device); + /** * @brief Initializes the session. * @see QDMI_session_init @@ -499,8 +506,8 @@ class Driver final : public Singleton { void materializeClientCatalog(); /// Opens a fresh device session with per-call overrides. - auto openFresh(std::string_view id, const DeviceSessionConfig& overrides) - -> std::shared_ptr; + auto openFresh(std::string_view id, const DeviceSessionConfig& overrides, + bool strict = false) -> std::shared_ptr; public: /** @@ -552,6 +559,11 @@ class Driver final : public Singleton { */ auto sessionAlloc(QDMI_Session* session) -> int; + /// Allocates a strict one-device session for the private Core extension. + auto sessionAllocForDevice(std::string_view id, + const DeviceSessionConfig& config, + QDMI_Session* session) -> int; + /** * @brief Frees a session. * @see QDMI_session_free diff --git a/python/mqt/core/_qdmi_discovery.py b/python/mqt/core/_qdmi_discovery.py new file mode 100644 index 0000000000..c684fa0d58 --- /dev/null +++ b/python/mqt/core/_qdmi_discovery.py @@ -0,0 +1,77 @@ +# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Discover installed QDMI manifests without importing provider packages.""" + +from __future__ import annotations + +import warnings +from importlib.metadata import EntryPoint, entry_points +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +_ENTRY_POINT_GROUP = "mqt.core.qdmi.manifests" + + +def _manifest_path(entry: EntryPoint) -> Path: + name = entry.name + if not name.endswith(".qdmi.json") or PurePosixPath(name).name != name or "\\" in name: + msg = f"invalid manifest basename {name!r}" + raise ValueError(msg) + + module_parts = entry.value.split(".") + if not module_parts or not all(part.isidentifier() for part in module_parts): + msg = f"invalid module anchor {entry.value!r}" + raise ValueError(msg) + + distribution = entry.dist + if distribution is None or distribution.read_text("RECORD") is None or distribution.files is None: + msg = "distribution has no RECORD file list" + raise ValueError(msg) + + prefix = PurePosixPath(*module_parts) + matches = [] + for file in distribution.files: + candidate = PurePosixPath(str(file)) + if ".." in candidate.parts or candidate.name != name: + continue + try: + candidate.relative_to(prefix) + except ValueError: + continue + matches.append(file) + if len(matches) != 1: + msg = f"expected one {name!r} below {prefix}, found {len(matches)}" + raise ValueError(msg) + return Path(str(distribution.locate_file(matches[0]))) + + +def discover_qdmi_manifests(add_manifest: Callable[[Path], None]) -> None: + """Stage each valid, explicitly advertised package manifest.""" + try: + entries = tuple(entry_points(group=_ENTRY_POINT_GROUP)) + except Exception as error: # ruff: ignore[blind-except] + warnings.warn( + f"Skipping QDMI manifest discovery: {error}", + RuntimeWarning, + stacklevel=2, + ) + return + + for entry in entries: + try: + add_manifest(_manifest_path(entry)) + except Exception as error: # ruff: ignore[blind-except] + warnings.warn( + f"Skipping QDMI manifest entry point {entry.name!r}: {error}", + RuntimeWarning, + stacklevel=2, + ) diff --git a/python/mqt/core/qdmi/__init__.pyi b/python/mqt/core/qdmi/__init__.pyi index 2e5837d12c..9751b6839b 100644 --- a/python/mqt/core/qdmi/__init__.pyi +++ b/python/mqt/core/qdmi/__init__.pyi @@ -6,13 +6,14 @@ # # Licensed under the MIT License -"""QDMI Client entities.""" +"""QDMI Client entities and MQT Core's default driver.""" import enum import os from collections.abc import Sequence from typing import overload +from mqt.core.qdmi import default_driver as default_driver from mqt.core.qdmi import slurm as slurm class ClientSession: diff --git a/python/mqt/core/qdmi/default_driver.pyi b/python/mqt/core/qdmi/default_driver.pyi new file mode 100644 index 0000000000..12a38ea4e5 --- /dev/null +++ b/python/mqt/core/qdmi/default_driver.pyi @@ -0,0 +1,36 @@ +# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Configure MQT Core's packaged QDMI Client driver.""" + +import os + +import mqt.core.qdmi + +def add_manifest(manifest_path: str | os.PathLike) -> None: + """Stage one installed package manifest before the default driver freezes.""" + +def open_device( + device_id: str, + *, + driver_path: str | os.PathLike | None = None, + base_url: str | None = None, + token: str | None = None, + auth_file: str | os.PathLike | None = None, + auth_url: str | None = None, + username: str | None = None, + password: str | None = None, + device_config: str | None = None, + device_config_file: str | os.PathLike | None = None, + custom1: str | None = None, + custom2: str | None = None, + custom3: str | None = None, + custom4: str | None = None, + custom5: str | None = None, +) -> mqt.core.qdmi.Device: + """Open one device through MQT Core's strict private driver extension.""" diff --git a/src/qdmi/Client.cpp b/src/qdmi/Client.cpp index 4e0f570b9b..bb1b26a85f 100644 --- a/src/qdmi/Client.cpp +++ b/src/qdmi/Client.cpp @@ -158,6 +158,11 @@ void closeLibrary(LibraryHandle library) { dlclose(library); } if (path.empty()) { throw std::invalid_argument("QDMI Client driver path must not be empty"); } + if (path.native().find(std::filesystem::path::value_type{}) != + std::filesystem::path::string_type::npos) { + throw std::invalid_argument( + "QDMI Client driver path must not contain null bytes"); + } std::error_code error; auto normalized = std::filesystem::weakly_canonical( std::filesystem::absolute(path, error), error); @@ -201,9 +206,26 @@ void closeLibrary(LibraryHandle library) { dlclose(library); } return packaged.has_parent_path() ? normalizePath(packaged) : packaged; } +[[nodiscard]] auto requestedDefaultDriverPath( + const std::optional& driverPath = std::nullopt) + -> std::filesystem::path { + if (driverPath) { + return normalizePath(*driverPath); + } + const auto packaged = packagedDriverPath(); + return packaged.has_parent_path() ? normalizePath(packaged) : packaged; +} + struct LoadedClient { + /// Private ABI v1 path arguments use UTF-8. + using AddManifest = int (*)(const char*); + using SessionAllocForDevice = int (*)(const char*, size_t, const char*, + QDMI_Session*); + LibraryHandle library{}; std::shared_ptr api; + AddManifest addManifest{}; + SessionAllocForDevice sessionAllocForDevice{}; LoadedClient(LibraryHandle selectedLibrary, std::shared_ptr selectedApi) @@ -219,7 +241,8 @@ struct LoadedClient { LoadedClient& operator=(const LoadedClient&) = delete; LoadedClient(LoadedClient&& other) noexcept : library(std::exchange(other.library, nullptr)), - api(std::move(other.api)) {} + api(std::move(other.api)), addManifest(other.addManifest), + sessionAllocForDevice(other.sessionAllocForDevice) {} }; template @@ -234,6 +257,13 @@ template return function; } +template +[[nodiscard]] auto loadOptionalSymbol(LibraryHandle library, const char* name) + -> Function { + /// NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return reinterpret_cast(findSymbol(library, name)); +} + [[nodiscard]] auto loadClient(const std::filesystem::path& path) -> LoadedClient { auto* const library = openLibrary(path); @@ -282,7 +312,13 @@ template LOAD_CLIENT_SYMBOL(deviceQueryOperationProperty, QDMI_device_query_operation_property); #undef LOAD_CLIENT_SYMBOL - return {library, std::move(api)}; + auto loaded = LoadedClient{library, std::move(api)}; + loaded.addManifest = loadOptionalSymbol( + library, "MQT_CORE_QDMI_driver_add_manifest_v1"); + loaded.sessionAllocForDevice = + loadOptionalSymbol( + library, "MQT_CORE_QDMI_driver_session_alloc_for_device_v1"); + return loaded; } catch (...) { closeLibrary(library); throw; @@ -295,6 +331,10 @@ struct ClientSelection { LibraryHandle library{}; std::shared_ptr api; std::filesystem::path path; + LoadedClient::AddManifest addManifest{}; + LoadedClient::SessionAllocForDevice sessionAllocForDevice{}; + std::unique_ptr stagedDefault; + std::filesystem::path stagedDefaultPath; }; [[nodiscard]] auto clientSelection() -> ClientSelection& { @@ -309,6 +349,8 @@ void selectClient(ClientSelection& selection, LoadedClient& loaded, selection.library = std::exchange(loaded.library, nullptr); selection.api = loaded.api; selection.path.swap(path); + selection.addManifest = loaded.addManifest; + selection.sessionAllocForDevice = loaded.sessionAllocForDevice; } using SessionGuard = @@ -322,6 +364,70 @@ void validateSessionAllocation(const int status, QDMI_Session session) { qdmi::throwIfError(status, "Allocating QDMI session"); } +[[nodiscard]] auto candidateClient(ClientSelection& selection, + const std::filesystem::path& path, + std::unique_ptr& local) + -> LoadedClient& { + if (selection.stagedDefault != nullptr && + path == selection.stagedDefaultPath) { + return *selection.stagedDefault; + } + local = std::make_unique(loadClient(path)); + return *local; +} + +template +[[nodiscard]] auto allocateRawTargetedSession( + std::shared_ptr api, + const LoadedClient::SessionAllocForDevice sessionAllocForDevice, + const std::string_view id, const std::string_view deviceSessionJson, + OnAllocated&& onAllocated) -> std::shared_ptr { + const auto* json = + deviceSessionJson.empty() ? nullptr : deviceSessionJson.data(); + const std::string deviceId{id}; + QDMI_Session session = nullptr; + const auto status = sessionAllocForDevice( + deviceId.c_str(), deviceSessionJson.size(), json, &session); + SessionGuard guard{session, api->sessionFree}; + if (session == nullptr && + (status == QDMI_SUCCESS || status == QDMI_WARN_GENERAL)) { + throw std::runtime_error( + "The targeted QDMI Client driver returned a null session"); + } + throwIfError(status, "Allocating targeted QDMI session"); + std::forward(onAllocated)(); + auto owner = std::make_shared(std::move(api), session); + /// The ClientSession now owns the raw QDMI session. + /// NOLINTNEXTLINE(bugprone-unused-return-value) + guard.release(); + return owner; +} + +[[nodiscard]] auto +initializeTargetedSession(const std::shared_ptr& owner) + -> QDMI_Device { + const auto& api = owner->api; + auto* const session = owner->handle; + throwIfError(api->sessionInit(session), "Initializing targeted QDMI session"); + size_t size = 0; + throwIfError(api->sessionQueryProperty(session, QDMI_SESSION_PROPERTY_DEVICES, + 0, nullptr, &size), + "Querying targeted QDMI device"); + if (size != sizeof(QDMI_Device)) { + throw std::runtime_error( + "A targeted QDMI session must expose exactly one device"); + } + QDMI_Device device = nullptr; + throwIfError(api->sessionQueryProperty(session, QDMI_SESSION_PROPERTY_DEVICES, + size, static_cast(&device), + nullptr), + "Querying targeted QDMI device"); + if (device == nullptr) { + throw std::runtime_error("A targeted QDMI session returned a null device"); + } + return device; +} + [[nodiscard]] auto allocateSession(const SessionConfig& config) -> std::shared_ptr { auto& selection = clientSelection(); @@ -345,7 +451,8 @@ void validateSessionAllocation(const int status, QDMI_Session session) { } auto path = requestedDriverPath(config); - auto loaded = loadClient(path); + std::unique_ptr local; + auto& loaded = candidateClient(selection, path, local); const std::shared_ptr api = loaded.api; QDMI_Session session = nullptr; const auto result = api->sessionAlloc(&session); @@ -353,6 +460,10 @@ void validateSessionAllocation(const int status, QDMI_Session session) { validateSessionAllocation(result, session); auto owner = std::make_shared(api, session); selectClient(selection, loaded, path); + if (selection.stagedDefault != nullptr) { + selection.stagedDefault.reset(); + selection.stagedDefaultPath.clear(); + } /// The ClientSession now owns the raw QDMI session. /// NOLINTNEXTLINE(bugprone-unused-return-value) guard.release(); @@ -360,6 +471,83 @@ void validateSessionAllocation(const int status, QDMI_Session session) { } } // namespace +void default_driver::addManifest(const std::filesystem::path& path) { + const auto canonical = normalizePath(path); + const auto manifestPath = detail::pathToUtf8(canonical); + auto& selection = clientSelection(); + const std::scoped_lock lock(selection.mutex); + if (selection.api != nullptr) { + if (selection.addManifest == nullptr) { + throw std::runtime_error( + "The selected QDMI Client driver does not support package manifests"); + } + throwIfError(selection.addManifest(manifestPath.c_str()), + "Staging QDMI package manifest"); + return; + } + + if (selection.stagedDefault == nullptr) { + selection.stagedDefaultPath = requestedDefaultDriverPath(); + selection.stagedDefault = + std::make_unique(loadClient(selection.stagedDefaultPath)); + } + if (selection.stagedDefault->addManifest == nullptr) { + throw std::runtime_error( + "MQT Core's packaged QDMI Client driver does not support manifests"); + } + throwIfError(selection.stagedDefault->addManifest(manifestPath.c_str()), + "Staging QDMI package manifest"); +} + +Device default_driver::openDevice( + const std::string_view id, const std::string_view deviceSessionJson, + const std::optional& driverPath) { + if (id.empty() || id.find('\0') != std::string_view::npos) { + throw std::invalid_argument( + "QDMI device ID must not be empty or contain null bytes"); + } + + auto& selection = clientSelection(); + std::shared_ptr owner; + { + const std::scoped_lock lock(selection.mutex); + if (selection.api != nullptr) { + if (driverPath && normalizePath(*driverPath) != selection.path) { + throw std::runtime_error( + "The QDMI Client driver is already selected for this process"); + } + if (selection.sessionAllocForDevice == nullptr) { + throw std::runtime_error("The selected QDMI Client driver does not " + "support targeted sessions"); + } + owner = allocateRawTargetedSession(selection.api, + selection.sessionAllocForDevice, id, + deviceSessionJson, [] {}); + } else { + auto selectedPath = requestedDefaultDriverPath(driverPath); + std::unique_ptr local; + auto& loaded = candidateClient(selection, selectedPath, local); + if (loaded.sessionAllocForDevice == nullptr) { + throw std::runtime_error("The selected QDMI Client driver does not " + "support targeted sessions"); + } + owner = allocateRawTargetedSession( + loaded.api, loaded.sessionAllocForDevice, id, deviceSessionJson, [&] { + selectClient(selection, loaded, selectedPath); + if (selection.stagedDefault != nullptr) { + selection.stagedDefault.reset(); + selection.stagedDefaultPath.clear(); + } + }); + } + } + auto* const device = initializeTargetedSession(owner); + /// Keep the explicit construction to avoid MSVC's dependent braced-init + /// evaluation bug (C4868). + /// NOLINTNEXTLINE(modernize-return-braced-init-list) + return Device(device, std::move(owner)); +} + detail::ClientSession::~ClientSession() { if (handle != nullptr) { api->sessionFree(handle); diff --git a/src/qdmi/driver/DeviceRegistry.cpp b/src/qdmi/driver/DeviceRegistry.cpp index a585acf6eb..231f82f265 100644 --- a/src/qdmi/driver/DeviceRegistry.cpp +++ b/src/qdmi/driver/DeviceRegistry.cpp @@ -10,16 +10,20 @@ #include "DeviceRegistry.hpp" +#include "qdmi/common/Common.hpp" #include "qdmi/driver/Driver.hpp" #include // NOLINT(misc-include-cleaner) +#include #include -#include +#include #include #include #include #include +#include +#include #include #include #include @@ -72,9 +76,21 @@ struct DefinitionPatch { std::filesystem::path source; }; +struct PackageManifestState { + std::mutex mutex; + std::vector paths; + std::map ids; + bool frozen = false; +}; + +[[nodiscard]] auto packageManifestState() -> PackageManifestState& { + static PackageManifestState state; + return state; +} + [[nodiscard]] auto sourceLabel(const std::filesystem::path& source, const std::string_view path) -> std::string { - return source.string() + ":" + std::string(path); + return pathToUtf8(source) + ":" + std::string(path); } void requireObject(const Json& value, const std::filesystem::path& source, @@ -182,18 +198,29 @@ parseSessionPatch(const Json& value, const std::filesystem::path& source, patch.deviceConfiguration = InlineDeviceConfiguration{.json = inlineConfig->dump()}; } else { - if (!fileConfig->is_string() || - fileConfig->get_ref().empty()) { + const auto file = optionalString(*config, "file", source, configPath); + if (!file || file->empty() || file->find('\0') != std::string::npos) { throw std::invalid_argument(sourceLabel(source, configPath + ".file") + - " must be a non-empty string"); + " must be a non-empty path without null " + "bytes"); } patch.deviceConfiguration = FileDeviceConfiguration{ - .path = resolvePath(fileConfig->get(), base), + .path = resolvePath(pathFromUtf8(*file), base), }; } } if (auto authFile = optionalString(value, "auth-file", source, path)) { - patch.authFile = resolvePath(*authFile, base); + if (authFile->empty() || authFile->find('\0') != std::string::npos) { + throw std::invalid_argument(sourceLabel(source, path + ".auth-file") + + " must be a non-empty path without null " + "bytes"); + } + patch.authFile = resolvePath(pathFromUtf8(*authFile), base); + } + if (patch.deviceConfiguration && (patch.custom1 || patch.custom2)) { + throw std::invalid_argument( + sourceLabel(source, path) + + " must not combine device-config with custom1 or custom2"); } return patch; } @@ -215,9 +242,18 @@ parseDevicePatch(const Json& value, const std::filesystem::path& source, patch.id = *id; patch.source = source; if (auto library = optionalString(value, "library", source, path)) { - patch.library = resolvePath(*library, base); + if (library->empty() || library->find('\0') != std::string::npos) { + throw std::invalid_argument(sourceLabel(source, path + ".library") + + " must be a non-empty path without null " + "bytes"); + } + patch.library = resolvePath(pathFromUtf8(*library), base); } patch.prefix = optionalString(value, "prefix", source, path); + if (patch.prefix && patch.prefix->find('\0') != std::string::npos) { + throw std::invalid_argument(sourceLabel(source, path + ".prefix") + + " must not contain null bytes"); + } if (const auto it = value.find("enabled"); it != value.end()) { if (!it->is_boolean()) { throw std::invalid_argument(sourceLabel(source, path + ".enabled") + @@ -277,12 +313,12 @@ parseDevicePatch(const Json& value, const std::filesystem::path& source, std::ifstream stream(path); if (!stream) { throw std::runtime_error("Cannot open QDMI configuration file: " + - path.string()); + pathToUtf8(path)); } try { return Json::parse(stream); } catch (const Json::parse_error& error) { - throw std::invalid_argument(path.string() + + throw std::invalid_argument(pathToUtf8(path) + ": invalid JSON: " + error.what()); } } @@ -349,27 +385,6 @@ void mergePatch(DefinitionPatch& target, const DefinitionPatch& source) { #endif } -[[nodiscard]] auto environment(const char* name) -> std::optional { -#ifdef _WIN32 - char* raw = nullptr; - size_t size = 0; - if (_dupenv_s(&raw, &size, name) != 0 || raw == nullptr) { - return std::nullopt; - } - const std::unique_ptr value(raw, &std::free); - if (*value == '\0') { - return std::nullopt; - } - return std::string(value.get()); -#else - if (const auto* value = std::getenv(name); - value != nullptr && *value != '\0') { - return std::string(value); - } - return std::nullopt; -#endif -} - void appendIfFile(std::vector& files, const std::filesystem::path& path) { const auto absolute = absolutePath(path); @@ -393,9 +408,10 @@ void appendFragments(std::vector& files, return; } std::vector found; + const auto manifestSuffix = std::filesystem::path{".qdmi.json"}.native(); for (const auto& entry : std::filesystem::directory_iterator(absolute)) { if (entry.is_regular_file() && - entry.path().filename().string().ends_with(".qdmi.json")) { + entry.path().filename().native().ends_with(manifestSuffix)) { found.emplace_back(entry.path()); } } @@ -429,8 +445,8 @@ void appendFragments(std::vector& files, appendFragments(files, root / "qdmi"); std::optional explicitFile; - if (auto value = environment("MQT_CORE_QDMI_CONFIG_FILE")) { - explicitFile = *value; + if (auto value = environmentUtf8("MQT_CORE_QDMI_CONFIG_FILE")) { + explicitFile = pathFromUtf8(*value); } if (explicitFile) { const auto resolved = @@ -438,28 +454,26 @@ void appendFragments(std::vector& files, if (!std::filesystem::is_regular_file(resolved)) { throw std::runtime_error("Explicit QDMI configuration file does not " "exist: " + - resolved.string()); + pathToUtf8(resolved)); } files.emplace_back(resolved); return files; } #ifdef _WIN32 - if (auto programData = environment("PROGRAMDATA")) { - appendIfFile(files, std::filesystem::path(*programData) / "mqt-core" / - "qdmi.json"); + if (auto programData = environmentUtf8("PROGRAMDATA")) { + appendIfFile(files, pathFromUtf8(*programData) / "mqt-core" / "qdmi.json"); } - if (auto appData = environment("APPDATA")) { - appendIfFile(files, - std::filesystem::path(*appData) / "mqt-core" / "qdmi.json"); + if (auto appData = environmentUtf8("APPDATA")) { + appendIfFile(files, pathFromUtf8(*appData) / "mqt-core" / "qdmi.json"); } #else appendIfFile(files, "/etc/mqt-core/qdmi.json"); - if (auto xdg = environment("XDG_CONFIG_HOME")) { - appendIfFile(files, std::filesystem::path(*xdg) / "mqt-core" / "qdmi.json"); - } else if (auto home = environment("HOME")) { - appendIfFile(files, std::filesystem::path(*home) / ".config" / "mqt-core" / - "qdmi.json"); + if (auto xdg = environmentUtf8("XDG_CONFIG_HOME")) { + appendIfFile(files, pathFromUtf8(*xdg) / "mqt-core" / "qdmi.json"); + } else if (auto home = environmentUtf8("HOME")) { + appendIfFile(files, + pathFromUtf8(*home) / ".config" / "mqt-core" / "qdmi.json"); } #endif if (auto project = @@ -475,12 +489,14 @@ void appendFragments(std::vector& files, return std::nullopt; } if (!patch.library || patch.library->empty()) { - throw std::invalid_argument(patch.source.string() + ": enabled device '" + - patch.id + "' is missing library"); + throw std::invalid_argument(pathToUtf8(patch.source) + + ": enabled device '" + patch.id + + "' is missing library"); } if (!patch.prefix || patch.prefix->empty()) { - throw std::invalid_argument(patch.source.string() + ": enabled device '" + - patch.id + "' is missing prefix"); + throw std::invalid_argument(pathToUtf8(patch.source) + + ": enabled device '" + patch.id + + "' is missing prefix"); } qdmi::DeviceDefinition definition; definition.id = patch.id; @@ -505,6 +521,121 @@ void appendFragments(std::vector& files, } // namespace +auto stagePackageManifest(const std::filesystem::path& path) -> int { + if (path.empty()) { + return QDMI_ERROR_INVALIDARGUMENT; + } + try { + std::error_code error; + const auto canonical = std::filesystem::weakly_canonical(path, error); + if (error) { + return QDMI_ERROR_LIBNOTFOUND; + } + + auto& state = packageManifestState(); + { + const std::scoped_lock lock(state.mutex); + if (std::ranges::find(state.paths, canonical) != state.paths.end()) { + return QDMI_SUCCESS; + } + if (state.frozen) { + return QDMI_ERROR_BADSTATE; + } + } + if (!std::filesystem::is_regular_file(canonical, error) || error) { + return QDMI_ERROR_LIBNOTFOUND; + } + + const auto patches = parseConfiguration(readJson(canonical), canonical, + canonical.parent_path()); + std::vector ids; + ids.reserve(patches.size()); + for (const auto& patch : patches) { + ids.emplace_back(patch.id); + if (const auto definition = materialize(patch); + definition && + !std::filesystem::is_regular_file(definition->library)) { + return QDMI_ERROR_LIBNOTFOUND; + } + } + + const std::scoped_lock lock(state.mutex); + if (std::ranges::find(state.paths, canonical) != state.paths.end()) { + return QDMI_SUCCESS; + } + if (state.frozen) { + return QDMI_ERROR_BADSTATE; + } + for (const auto& id : ids) { + if (state.ids.contains(id)) { + return QDMI_ERROR_INVALIDARGUMENT; + } + } + auto paths = state.paths; + auto storedIds = state.ids; + paths.emplace_back(canonical); + for (auto& id : ids) { + storedIds.emplace(std::move(id), canonical); + } + state.paths.swap(paths); + state.ids.swap(storedIds); + return QDMI_SUCCESS; + } catch (const std::bad_alloc&) { + return QDMI_ERROR_OUTOFMEM; + } catch (const std::invalid_argument&) { + return QDMI_ERROR_INVALIDARGUMENT; + } catch (...) { + return QDMI_ERROR_FATAL; + } +} + +auto parseDeviceSessionJson(const char* const data, const size_t size, + DeviceSessionConfig& config) -> int { + config = {}; + if (data == nullptr && size == 0) { + return QDMI_SUCCESS; + } + try { + const auto value = Json::parse(std::string_view{data, size}); + const auto patch = parseSessionPatch(value, "", "$", + std::filesystem::current_path()); + config.baseUrl = patch.baseUrl; + config.token = patch.token; + config.authFile = patch.authFile; + config.authUrl = patch.authUrl; + config.username = patch.username; + config.password = patch.password; + config.deviceConfiguration = patch.deviceConfiguration; + config.custom1 = patch.custom1; + config.custom2 = patch.custom2; + config.custom3 = patch.custom3; + config.custom4 = patch.custom4; + config.custom5 = patch.custom5; + return QDMI_SUCCESS; + } catch (const std::bad_alloc&) { + return QDMI_ERROR_OUTOFMEM; + } catch (const Json::parse_error&) { + return QDMI_ERROR_INVALIDARGUMENT; + } catch (const std::invalid_argument&) { + return QDMI_ERROR_INVALIDARGUMENT; + } catch (...) { + return QDMI_ERROR_FATAL; + } +} + +auto freezePackageManifests() -> std::vector { + auto& state = packageManifestState(); + const std::scoped_lock lock(state.mutex); + state.frozen = true; + return state.paths; +} + +void rollbackPackageManifestFreeze() { + auto& state = packageManifestState(); + const std::scoped_lock lock(state.mutex); + state.frozen = false; +} + DeviceRegistry::DeviceRegistry() { std::map merged; const auto mergePatches = [&merged](std::vector patches) { @@ -517,11 +648,14 @@ DeviceRegistry::DeviceRegistry() { } }; - for (const auto& file : discoverFiles()) { + auto files = freezePackageManifests(); + const auto discovered = discoverFiles(); + files.insert(files.end(), discovered.begin(), discovered.end()); + for (const auto& file : files) { mergePatches(parseConfiguration(readJson(file), file, file.parent_path())); } const auto inlineBase = std::filesystem::current_path(); - if (auto inlineJson = environment("MQT_CORE_QDMI_CONFIG_JSON")) { + if (auto inlineJson = environmentUtf8("MQT_CORE_QDMI_CONFIG_JSON")) { try { mergePatches(parseConfiguration( Json::parse(*inlineJson), "", inlineBase)); diff --git a/src/qdmi/driver/DeviceRegistry.hpp b/src/qdmi/driver/DeviceRegistry.hpp index 6a9441e4fb..9b70e5518c 100644 --- a/src/qdmi/driver/DeviceRegistry.hpp +++ b/src/qdmi/driver/DeviceRegistry.hpp @@ -12,6 +12,8 @@ #include "qdmi/driver/Driver.hpp" +#include +#include #include #include #include @@ -21,6 +23,20 @@ namespace qdmi::detail { /// Rejects IDs that the QDMI string-property ABI cannot represent. void validateDeviceId(std::string_view id); +/// Stages one low-precedence package manifest before the driver is frozen. +auto stagePackageManifest(const std::filesystem::path& path) -> int; + +/// Freezes and returns the staged package manifests. +[[nodiscard]] auto freezePackageManifests() + -> std::vector; + +/// Reopens package-manifest staging after driver construction fails. +void rollbackPackageManifestFreeze(); + +/// Parses one strict JSON object with the manifest session grammar. +auto parseDeviceSessionJson(const char* data, size_t size, + DeviceSessionConfig& config) -> int; + /// Discovers configured QDMI devices without loading their libraries. class DeviceRegistry { public: diff --git a/src/qdmi/driver/Driver.cpp b/src/qdmi/driver/Driver.cpp index e2495954fe..056111cdf7 100644 --- a/src/qdmi/driver/Driver.cpp +++ b/src/qdmi/driver/Driver.cpp @@ -105,11 +105,38 @@ namespace { #define DL_CLOSE(lib) dlclose((lib)) #endif +namespace { +class DeviceStatusError final : public std::runtime_error { +public: + DeviceStatusError(const int status, const std::string& message) + : std::runtime_error(message), status_(status) {} + + [[nodiscard]] auto status() const -> int { return status_; } + +private: + int status_; +}; + +[[nodiscard]] auto acceptsDeviceWarning(const int status, + const std::string_view action) -> bool { + if (status == QDMI_SUCCESS) { + return true; + } + if (status == QDMI_WARN_GENERAL) { + qdmi::diagnostics::warn("QDMI device {} returned a general warning", + action); + return true; + } + return false; +} +} // namespace + DynamicDeviceLibrary::DynamicDeviceLibrary(const std::string& libName, const std::string& prefix) : libHandle_(DL_OPEN(libName.c_str())) { if (libHandle_ == nullptr) { - throw std::runtime_error("Couldn't open the device library: " + libName); + throw DeviceStatusError(QDMI_ERROR_LIBNOTFOUND, + "Couldn't open the device library: " + libName); } //===----------------------------------------------------------------------===// @@ -121,7 +148,8 @@ DynamicDeviceLibrary::DynamicDeviceLibrary(const std::string& libName, (symbol) = reinterpret_cast( \ DL_SYM(libHandle_, symbolName.c_str())); \ if ((symbol) == nullptr) { \ - throw std::runtime_error("Failed to load symbol: " + symbolName); \ + throw DeviceStatusError(QDMI_ERROR_FATAL, \ + "Failed to load symbol: " + symbolName); \ } \ } @@ -161,7 +189,12 @@ DynamicDeviceLibrary::DynamicDeviceLibrary(const std::string& libName, // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast) // Initialize the device library only after every required symbol is // available. - throwIfError(device_initialize(), "Failed to initialize device library"); + if (const auto status = device_initialize(); status == QDMI_WARN_GENERAL) { + qdmi::diagnostics::warn( + "QDMI device library initialization returned a warning"); + } else if (status != QDMI_SUCCESS) { + throw DeviceStatusError(status, "Failed to initialize device library"); + } } catch (...) { DL_CLOSE(libHandle_); libHandle_ = nullptr; @@ -187,7 +220,7 @@ namespace { struct DynamicLibraryCache { std::mutex mutex; std::map, - std::weak_ptr> + std::shared_ptr> libraries; }; @@ -202,17 +235,19 @@ struct DynamicLibraryCache { auto& cache = dynamicLibraryCache(); const std::scoped_lock lock(cache.mutex); std::error_code error; + const auto requested = detail::pathFromUtf8(libName); auto canonicalPath = std::filesystem::weakly_canonical( - std::filesystem::absolute(std::filesystem::path(libName), error), error); + std::filesystem::absolute(requested, error), error); if (error) { - canonicalPath = std::filesystem::path(libName).lexically_normal(); + canonicalPath = requested.lexically_normal(); } const auto key = std::pair{detail::pathToUtf8(canonicalPath), prefix}; - if (const auto library = cache.libraries[key].lock()) { - return library; + if (const auto library = cache.libraries.find(key); + library != cache.libraries.end()) { + return library->second; } auto library = std::make_shared(libName, prefix); - cache.libraries[key] = library; + cache.libraries.emplace(key, library); return library; } @@ -252,34 +287,49 @@ void applyOverride(std::optional& value, QDMI_Device_impl_d::QDMI_Device_impl_d( std::shared_ptr lib, const qdmi::DeviceSessionConfig& config, std::string id, - QDMI_Child_Device_impl_d* const childDevice) + QDMI_Child_Device_impl_d* const childDevice, const bool strict) : id_(std::move(id)), library_(std::move(lib)) { - if (library_->device_session_alloc(&deviceSession_) != QDMI_SUCCESS) { - throw std::runtime_error("Failed to allocate device session"); + const auto allocationStatus = library_->device_session_alloc(&deviceSession_); + if (deviceSession_ == nullptr) { + if (allocationStatus == QDMI_SUCCESS || + allocationStatus == QDMI_WARN_GENERAL) { + throw qdmi::DeviceStatusError( + QDMI_ERROR_FATAL, + "Device returned a null session after session allocation"); + } + throw qdmi::DeviceStatusError(allocationStatus, + "Failed to allocate device session"); + } + if (!qdmi::acceptsDeviceWarning(allocationStatus, "session allocation")) { + library_->device_session_free(deviceSession_); + deviceSession_ = nullptr; + throw qdmi::DeviceStatusError(allocationStatus, + "Failed to allocate device session"); } // Set device session parameters from config - auto setParameter = [this](const std::optional& value, - QDMI_Device_Session_Parameter param) { + auto setParameter = [this, strict](const std::optional& value, + QDMI_Device_Session_Parameter param) { if (value && library_->device_session_set_parameter) { const auto status = static_cast(library_->device_session_set_parameter( deviceSession_, param, value->size() + 1, value->c_str())); - if (status == QDMI_SUCCESS) { + if (qdmi::acceptsDeviceWarning(status, "session parameter update")) { return; } - if (status == QDMI_ERROR_NOTSUPPORTED) { + if (status == QDMI_ERROR_NOTSUPPORTED && !strict) { qdmi::diagnostics::info( "Device session parameter {} not supported by device (skipped)", qdmi::toString(param)); return; } library_->device_session_free(deviceSession_); + deviceSession_ = nullptr; std::ostringstream ss; ss << "Failed to set device session parameter " << qdmi::toString(param) << ": " << qdmi::toString(status); - throw std::runtime_error(ss.str()); + throw qdmi::DeviceStatusError(status, ss.str()); } }; @@ -326,19 +376,21 @@ QDMI_Device_impl_d::QDMI_Device_impl_d( static_cast(library_->device_session_set_parameter( deviceSession_, QDMI_DEVICE_SESSION_PARAMETER_CHILDDEVICE, sizeof(QDMI_Child_Device), static_cast(&childDevice))); - if (status != QDMI_SUCCESS) { + if (!qdmi::acceptsDeviceWarning(status, "child-device selection")) { library_->device_session_free(deviceSession_); deviceSession_ = nullptr; std::ostringstream ss; ss << "Failed to select child device: " << qdmi::toString(status); - throw std::runtime_error(ss.str()); + throw qdmi::DeviceStatusError(status, ss.str()); } } - if (library_->device_session_init(deviceSession_) != QDMI_SUCCESS) { + const auto initStatus = library_->device_session_init(deviceSession_); + if (!qdmi::acceptsDeviceWarning(initStatus, "session initialization")) { library_->device_session_free(deviceSession_); deviceSession_ = nullptr; - throw std::runtime_error("Failed to initialize device session"); + throw qdmi::DeviceStatusError(initStatus, + "Failed to initialize device session"); } // Child sessions represent leaf devices in the QDMI multicore @@ -355,14 +407,17 @@ QDMI_Device_impl_d::QDMI_Device_impl_d( if (status == QDMI_ERROR_NOTSUPPORTED) { return; } - if (status != QDMI_SUCCESS || childrenSize % sizeof(QDMI_Child_Device) != 0) { + if (!qdmi::acceptsDeviceWarning(status, "child-device size query")) { library_->device_session_free(deviceSession_); deviceSession_ = nullptr; - if (status != QDMI_SUCCESS) { - throw std::runtime_error("Failed to query child devices: " + - std::string(qdmi::toString(status))); - } - throw std::runtime_error("Device returned an invalid child device list"); + throw qdmi::DeviceStatusError(status, + "Failed to query child devices: " + + std::string(qdmi::toString(status))); + } + if (childrenSize % sizeof(QDMI_Child_Device) != 0) { + library_->device_session_free(deviceSession_); + deviceSession_ = nullptr; + throw std::invalid_argument("Device returned an invalid child device list"); } std::vector children(childrenSize / @@ -372,11 +427,17 @@ QDMI_Device_impl_d::QDMI_Device_impl_d( static_cast(library_->device_session_query_device_property( deviceSession_, QDMI_DEVICE_PROPERTY_CHILDDEVICES, childrenSize, static_cast(children.data()), nullptr)); - if (status != QDMI_SUCCESS) { + if (!qdmi::acceptsDeviceWarning(status, "child-device query")) { + library_->device_session_free(deviceSession_); + deviceSession_ = nullptr; + throw qdmi::DeviceStatusError(status, + "Failed to query child devices: " + + std::string(qdmi::toString(status))); + } + if (std::ranges::find(children, nullptr) != children.end()) { library_->device_session_free(deviceSession_); deviceSession_ = nullptr; - throw std::runtime_error("Failed to query child devices: " + - std::string(qdmi::toString(status))); + throw std::invalid_argument("Device returned a null child device handle"); } } @@ -386,7 +447,7 @@ QDMI_Device_impl_d::QDMI_Device_impl_d( auto childId = id_.empty() ? std::string{} : id_ + "/child/" + std::to_string(index); childDevices_.emplace_back(std::make_unique( - library_, config, std::move(childId), children[index])); + library_, config, std::move(childId), children[index], strict)); } } catch (...) { childDevices_.clear(); @@ -535,6 +596,10 @@ QDMI_Session_impl_d::QDMI_Session_impl_d( const std::vector& devices) : devices_(devices) {} +QDMI_Session_impl_d::QDMI_Session_impl_d( + std::shared_ptr device) + : devices_{device.get()}, ownedDevice_(std::move(device)) {} + QDMI_Job_impl_d::~QDMI_Job_impl_d() { device_->getLibrary().device_job_free(deviceJob_); } @@ -632,7 +697,8 @@ auto QDMI_Session_impl_d::setParameter(QDMI_Session_Parameter param, auto QDMI_Session_impl_d::querySessionProperty(QDMI_Session_Property prop, size_t size, void* value, size_t* sizeRet) const -> int { - if ((value != nullptr && size == 0) || prop >= QDMI_SESSION_PROPERTY_MAX) { + if ((value != nullptr && size == 0) || + IS_INVALID_ARGUMENT(prop, QDMI_SESSION_PROPERTY)) { return QDMI_ERROR_INVALIDARGUMENT; } if (status_ != qdmi::SessionStatus::INITIALIZED) { @@ -656,21 +722,46 @@ auto QDMI_Session_impl_d::querySessionProperty(QDMI_Session_Property prop, namespace qdmi { namespace { -void validateDefinition(const DeviceDefinition& definition) { - detail::validateDeviceId(definition.id); - if (definition.library.empty()) { - throw std::invalid_argument("Device definition library must not be empty"); +void validatePath(const std::filesystem::path& path, + const std::string_view description) { + if (path.empty()) { + throw std::invalid_argument(std::string(description) + + " must not be empty"); + } + if (path.native().find(std::filesystem::path::value_type{}) != + std::filesystem::path::string_type::npos) { + throw std::invalid_argument(std::string(description) + + " must not contain null bytes"); + } +} + +void validateSessionConfig(const DeviceSessionConfig& config) { + if (config.authFile) { + validatePath(*config.authFile, "Device session auth file"); } - if (definition.prefix.empty()) { - throw std::invalid_argument("Device definition prefix must not be empty"); + if (const auto* source = config.deviceConfiguration + ? std::get_if( + &*config.deviceConfiguration) + : nullptr) { + validatePath(source->path, "Device configuration file"); } - if (definition.session.deviceConfiguration && - (definition.session.custom1 || definition.session.custom2)) { + if (config.deviceConfiguration && (config.custom1 || config.custom2)) { throw std::invalid_argument( "Typed device configuration cannot be combined with raw custom1 or " "custom2 session parameters"); } } + +void validateDefinition(const DeviceDefinition& definition) { + detail::validateDeviceId(definition.id); + validatePath(definition.library, "Device definition library"); + if (definition.prefix.empty() || + definition.prefix.find('\0') != std::string::npos) { + throw std::invalid_argument( + "Device definition prefix must not be empty or contain null bytes"); + } + validateSessionConfig(definition.session); +} } // namespace auto Driver::get() -> Driver& { @@ -680,12 +771,17 @@ auto Driver::get() -> Driver& { } Driver::Driver() { - const detail::DeviceRegistry registry; - disabledDeviceIds_.insert(registry.disabledIds().begin(), - registry.disabledIds().end()); - for (const auto& definition : registry.definitions()) { - registerDevice(definition); - clientDefinitionIds_.emplace_back(definition.id); + try { + const detail::DeviceRegistry registry; + disabledDeviceIds_.insert(registry.disabledIds().begin(), + registry.disabledIds().end()); + for (const auto& definition : registry.definitions()) { + registerDevice(definition); + clientDefinitionIds_.emplace_back(definition.id); + } + } catch (...) { + detail::rollbackPackageManifestFreeze(); + throw; } } @@ -811,27 +907,30 @@ auto Driver::open(const std::string_view id) -> QDMI_Device { } auto Driver::openFresh(const std::string_view id, - const DeviceSessionConfig& overrides) + const DeviceSessionConfig& overrides, const bool strict) -> std::shared_ptr { DeviceDefinition definition; { const std::scoped_lock lock(stateMutex_); if (disabledDeviceIds_.contains(std::string(id))) { - throw std::runtime_error("QDMI device ID '" + std::string(id) + - "' is disabled by configuration"); + throw DeviceStatusError(QDMI_ERROR_PERMISSIONDENIED, + "QDMI device ID '" + std::string(id) + + "' is disabled by configuration"); } const auto registered = std::ranges::find(definitions_, id, &DeviceDefinition::id); if (registered == definitions_.end()) { - throw std::out_of_range("Unknown QDMI device ID '" + std::string(id) + - "'"); + throw DeviceStatusError(QDMI_ERROR_NOTFOUND, "Unknown QDMI device ID '" + + std::string(id) + "'"); } definition = *registered; } - return std::make_shared( - getDynamicDeviceLibrary(detail::pathToUtf8(definition.library), - definition.prefix), - mergeSessionConfig(definition.session, overrides), definition.id); + const auto config = mergeSessionConfig(definition.session, overrides); + validateSessionConfig(config); + auto library = getDynamicDeviceLibrary(detail::pathToUtf8(definition.library), + definition.prefix); + return std::make_shared(std::move(library), config, + definition.id, nullptr, strict); } void Driver::materializeClientCatalog() { @@ -884,6 +983,32 @@ auto Driver::sessionAlloc(QDMI_Session* session) -> int { return QDMI_SUCCESS; } +auto Driver::sessionAllocForDevice(const std::string_view id, + const DeviceSessionConfig& config, + QDMI_Session* const session) -> int { + if (id.empty() || session == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + *session = nullptr; + try { + auto uniqueSession = + std::make_unique(openFresh(id, config, true)); + auto* const sessionHandle = uniqueSession.get(); + const std::scoped_lock lock(stateMutex_); + sessions_.emplace(sessionHandle, std::move(uniqueSession)); + *session = sessionHandle; + return QDMI_SUCCESS; + } catch (const DeviceStatusError& error) { + return error.status(); + } catch (const std::bad_alloc&) { + return QDMI_ERROR_OUTOFMEM; + } catch (const std::invalid_argument&) { + return QDMI_ERROR_INVALIDARGUMENT; + } catch (...) { + return QDMI_ERROR_FATAL; + } +} + auto Driver::sessionFree(QDMI_Session session) -> void { std::unique_ptr ownedSession; { @@ -900,6 +1025,53 @@ uint32_t QDMI_driver_get_client_abi_version() { return QDMI_CLIENT_ABI_VERSION; } +/// The private C ABI fixes these exported symbol names. +/// NOLINTBEGIN(readability-identifier-naming) +extern "C" QDMI_DRIVER_EXPORT int +MQT_CORE_QDMI_driver_add_manifest_v1(const char* const manifestPath) { + if (manifestPath == nullptr || *manifestPath == '\0') { + return QDMI_ERROR_INVALIDARGUMENT; + } + try { + return qdmi::detail::stagePackageManifest( + qdmi::detail::pathFromUtf8(manifestPath)); + } catch (const std::bad_alloc&) { + return QDMI_ERROR_OUTOFMEM; + } catch (...) { + return QDMI_ERROR_INVALIDARGUMENT; + } +} + +extern "C" QDMI_DRIVER_EXPORT int +MQT_CORE_QDMI_driver_session_alloc_for_device_v1( + const char* const deviceId, const size_t deviceSessionJsonSize, + const char* const deviceSessionJson, QDMI_Session* const session) { + if (session == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + *session = nullptr; + if (deviceId == nullptr || *deviceId == '\0' || + ((deviceSessionJson == nullptr) != (deviceSessionJsonSize == 0))) { + return QDMI_ERROR_INVALIDARGUMENT; + } + qdmi::DeviceSessionConfig config; + if (const auto status = qdmi::detail::parseDeviceSessionJson( + deviceSessionJson, deviceSessionJsonSize, config); + status != QDMI_SUCCESS) { + return status; + } + try { + return qdmi::Driver::get().sessionAllocForDevice(deviceId, config, session); + } catch (const std::bad_alloc&) { + return QDMI_ERROR_OUTOFMEM; + } catch (const std::invalid_argument&) { + return QDMI_ERROR_INVALIDARGUMENT; + } catch (...) { + return QDMI_ERROR_FATAL; + } +} +/// NOLINTEND(readability-identifier-naming) + int QDMI_session_alloc(QDMI_Session* session) { if (session == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; diff --git a/test/python/qdmi/test_default_driver.py b/test/python/qdmi/test_default_driver.py new file mode 100644 index 0000000000..be9397a2e2 --- /dev/null +++ b/test/python/qdmi/test_default_driver.py @@ -0,0 +1,77 @@ +# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Test MQT Core's optional packaged-driver extension.""" + +from __future__ import annotations + +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest + +from mqt.core.qdmi import default_driver + +if TYPE_CHECKING: + from pathlib import Path + + +def test_add_manifest_reports_invalid_files(tmp_path: Path) -> None: + """Explicit manifest staging reports errors instead of warning and skipping.""" + malformed = tmp_path / "malformed.qdmi.json" + malformed.write_text("{") + script = """ +import sys +from pathlib import Path +from mqt.core.qdmi import default_driver + +try: + default_driver.add_manifest(Path(sys.argv[1])) +except RuntimeError as error: + assert "Library not found" in str(error) +else: + raise AssertionError("missing manifest must fail") + +try: + default_driver.add_manifest(Path(sys.argv[2])) +except ValueError as error: + assert "Invalid argument" in str(error) +else: + raise AssertionError("malformed manifest must fail") +""" + result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] + [sys.executable, "-c", script, tmp_path / "missing.qdmi.json", malformed], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_open_device_uses_strict_fresh_sessions() -> None: + """Targeted opens apply strict overrides and own independent sessions.""" + first = default_driver.open_device("mqt.ddsim.default") + second = default_driver.open_device("mqt.ddsim.default") + + assert first.id == "mqt.ddsim.default" + assert second.id == "mqt.ddsim.default" + assert first != second + + with pytest.raises(RuntimeError, match="Not supported"): + default_driver.open_device("mqt.ddsim.default", custom4="strict") + + +def test_open_device_rejects_conflicting_device_configuration() -> None: + """The Python wrapper rejects two sources for one typed configuration.""" + with pytest.raises(ValueError, match="mutually exclusive"): + default_driver.open_device( + "mqt.sc.default", + device_config="{}", + device_config_file="device.json", + ) diff --git a/test/python/qdmi/test_discovery.py b/test/python/qdmi/test_discovery.py new file mode 100644 index 0000000000..da4d292523 --- /dev/null +++ b/test/python/qdmi/test_discovery.py @@ -0,0 +1,126 @@ +# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Test metadata-only QDMI manifest discovery.""" + +from __future__ import annotations + +import sys +from pathlib import Path, PurePosixPath +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import pytest + +from mqt.core import _qdmi_discovery # ruff: ignore[import-private-name] + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class _Distribution: + def __init__(self, root: Path, files: list[str] | None, *, record: str | None = "") -> None: + self.root = root + self.files = None if files is None else [PurePosixPath(file) for file in files] + self.record = record + + def locate_file(self, file: PurePosixPath) -> Path: + return self.root / file + + def read_text(self, filename: str) -> str | None: + assert filename == "RECORD" + return self.record + + +def _entry( + root: Path, + files: list[str] | None, + *, + name: str = "device.qdmi.json", + value: str = "vendor.device", + record: str | None = "", +) -> object: + return SimpleNamespace(name=name, value=value, dist=_Distribution(root, files, record=record)) + + +def test_discovers_record_manifest_without_importing_owner(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Resolve a manifest through wheel metadata without importing its package.""" + manifest = "vendor/device/data/lib/device.qdmi.json" + (tmp_path / "vendor").mkdir() + (tmp_path / "vendor" / "__init__.py").write_text("raise RuntimeError\n") + path = tmp_path / manifest + path.parent.mkdir(parents=True) + path.write_text("{}") + monkeypatch.setattr(_qdmi_discovery, "entry_points", lambda **_: [_entry(tmp_path, [manifest])]) + + discovered: list[Path] = [] + _qdmi_discovery.discover_qdmi_manifests(discovered.append) + + assert discovered == [path] + assert "vendor" not in sys.modules + + +def test_skips_failed_entry_point_enumeration(monkeypatch: pytest.MonkeyPatch) -> None: + """Warn once when the metadata backend cannot enumerate entry points.""" + + def fail_enumeration(**_: object) -> Iterator[object]: + yield from () + msg = "broken metadata" + raise RuntimeError(msg) + + monkeypatch.setattr(_qdmi_discovery, "entry_points", fail_enumeration) + with pytest.warns(RuntimeWarning, match="Skipping QDMI manifest discovery") as warnings: + _qdmi_discovery.discover_qdmi_manifests(lambda _: pytest.fail("must skip")) + assert len(warnings) == 1 + + +def test_skips_file_list_without_record(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Reject file lists that do not come from wheel RECORD metadata.""" + manifest = "vendor/device/data/device.qdmi.json" + monkeypatch.setattr( + _qdmi_discovery, + "entry_points", + lambda **_: [_entry(tmp_path, [manifest], record=None)], + ) + + with pytest.warns(RuntimeWarning, match="no RECORD") as warnings: + _qdmi_discovery.discover_qdmi_manifests(lambda _: pytest.fail("must skip")) + assert len(warnings) == 1 + + +@pytest.mark.parametrize( + ("files", "name", "value"), + [ + (None, "device.qdmi.json", "vendor.device"), + ( + ["vendor/device/a/device.qdmi.json", "vendor/device/b/device.qdmi.json"], + "device.qdmi.json", + "vendor.device", + ), + (["../device.qdmi.json"], "device.qdmi.json", "vendor.device"), + (["other/device.qdmi.json"], "device.qdmi.json", "vendor.device"), + (["vendor/device/data/device.qdmi.json"], "../device.qdmi.json", "vendor.device"), + (["vendor/device/data/device.qdmi.json"], "device.qdmi.json", "vendor/device"), + ], +) +def test_skips_invalid_manifest_metadata( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + files: list[str] | None, + name: str, + value: str, +) -> None: + """Warn once and skip malformed or ambiguous manifest metadata.""" + monkeypatch.setattr( + _qdmi_discovery, + "entry_points", + lambda **_: [_entry(tmp_path, files, name=name, value=value)], + ) + with pytest.warns(RuntimeWarning, match="Skipping QDMI manifest") as warnings: + _qdmi_discovery.discover_qdmi_manifests(lambda _: pytest.fail("must skip")) + assert len(warnings) == 1 diff --git a/test/qdmi/CMakeLists.txt b/test/qdmi/CMakeLists.txt index 5edfec1600..09f890f922 100644 --- a/test/qdmi/CMakeLists.txt +++ b/test/qdmi/CMakeLists.txt @@ -26,6 +26,27 @@ if(TARGET MQT::CoreQDMI) target_compile_definitions( mqt-core-qdmi-fake-client PRIVATE QDMI_driver_EXPORTS TEST_CLIENT_ABI=QDMI_CLIENT_ABI_VERSION TEST_FULL_CLIENT) + add_library(mqt-core-qdmi-targeted-failure-client SHARED client_runtime_driver.cpp) + target_link_libraries(mqt-core-qdmi-targeted-failure-client PRIVATE qdmi::qdmi) + target_compile_definitions( + mqt-core-qdmi-targeted-failure-client + PRIVATE QDMI_driver_EXPORTS TEST_CLIENT_ABI=QDMI_CLIENT_ABI_VERSION TEST_FULL_CLIENT + TEST_TARGETED_EXTENSION) + add_library(mqt-core-qdmi-targeted-init-failure-client SHARED client_runtime_driver.cpp) + target_link_libraries(mqt-core-qdmi-targeted-init-failure-client PRIVATE qdmi::qdmi) + target_compile_definitions( + mqt-core-qdmi-targeted-init-failure-client + PRIVATE QDMI_driver_EXPORTS TEST_CLIENT_ABI=QDMI_CLIENT_ABI_VERSION TEST_FULL_CLIENT + TEST_TARGETED_INIT_FAILURE_EXTENSION) + + set(runtime_extension_manifest + "${CMAKE_CURRENT_BINARY_DIR}/$/client-runtime/runtime-extension-package.qdmi.json") + file( + GENERATE + OUTPUT "${runtime_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.runtime\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) if(WIN32) # Discover tests after the Driver and device runtime copies finish. @@ -37,14 +58,36 @@ if(TARGET MQT::CoreQDMI) endif() target_compile_definitions( mqt-core-qdmi-client-runtime-test - PRIVATE "MQT_CORE_QDMI_TEST_DRIVER=\"$\"" - "MQT_CORE_QDMI_INCOMPLETE_DRIVER=\"$\"" - "MQT_CORE_QDMI_INCOMPATIBLE_DRIVER=\"$\"" - ) - add_dependencies(mqt-core-qdmi-client-runtime-test mqt-core-qdmi-incomplete-client - mqt-core-qdmi-incompatible-client mqt-core-qdmi-fake-client) + PRIVATE + "MQT_CORE_QDMI_TEST_DRIVER=\"$\"" + "MQT_CORE_QDMI_INCOMPLETE_DRIVER=\"$\"" + "MQT_CORE_QDMI_INCOMPATIBLE_DRIVER=\"$\"" + "MQT_CORE_QDMI_TARGETED_FAILURE_DRIVER=\"$\"" + "MQT_CORE_QDMI_RUNTIME_EXTENSION_MANIFEST=\"${runtime_extension_manifest}\"") + add_dependencies( + mqt-core-qdmi-client-runtime-test mqt-core-qdmi-incomplete-client + mqt-core-qdmi-incompatible-client mqt-core-qdmi-fake-client + mqt-core-qdmi-targeted-failure-client mqt-core-qdmi-session-device) mqt_copy_qdmi_runtime(mqt-core-qdmi-client-runtime-test) + if(WIN32) + set(CMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE PRE_TEST) + endif() + package_add_test(mqt-core-qdmi-targeted-selection-test MQT::CoreQDMI + test_client_targeted_selection.cpp) + if(WIN32) + unset(CMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE) + endif() + target_compile_definitions( + mqt-core-qdmi-targeted-selection-test + PRIVATE + "MQT_CORE_QDMI_TEST_DRIVER=\"$\"" + "MQT_CORE_QDMI_TARGETED_INIT_FAILURE_DRIVER=\"$\"" + ) + add_dependencies(mqt-core-qdmi-targeted-selection-test mqt-core-qdmi-fake-client + mqt-core-qdmi-targeted-init-failure-client) + mqt_copy_qdmi_runtime(mqt-core-qdmi-targeted-selection-test) + add_executable(mqt-core-qdmi-packaged-runtime-test test_packaged_runtime.cpp) target_link_libraries(mqt-core-qdmi-packaged-runtime-test PRIVATE MQT::CoreQDMI MQT::ProjectWarnings) @@ -59,6 +102,116 @@ if(TARGET MQT::CoreQDMI) "$" "./$") + set(extension_fixture_dir "${CMAKE_CURRENT_BINARY_DIR}/$/default-driver-extension") + set(extension_manifest "${extension_fixture_dir}/package-session.qdmi.json") + set(unicode_extension_manifest_source + "${extension_fixture_dir}/unicode-package-session.qdmi.json") + set(late_extension_manifest "${extension_fixture_dir}/late-package-session.qdmi.json") + set(post_freeze_extension_manifest + "${extension_fixture_dir}/post-freeze-package-session.qdmi.json") + set(conflicting_extension_manifest + "${extension_fixture_dir}/conflicting-package-session.qdmi.json") + set(incompatible_extension_manifest + "${extension_fixture_dir}/incompatible-package-session.qdmi.json") + set(malformed_extension_manifest "${extension_fixture_dir}/malformed-package-session.qdmi.json") + set(missing_library_extension_manifest + "${extension_fixture_dir}/missing-library-package-session.qdmi.json") + set(missing_prefix_extension_manifest + "${extension_fixture_dir}/missing-prefix-package-session.qdmi.json") + set(nul_extension_manifest "${extension_fixture_dir}/nul-package-session.qdmi.json") + set(conflicting_config_manifest + "${extension_fixture_dir}/conflicting-config-package-session.qdmi.json") + file( + GENERATE + OUTPUT "${extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.session\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\", \"session\": {\"custom4\": \"idle\"}},\n {\"id\": \"package.child-error\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\", \"session\": {\"custom5\": \"permission-denied\"}}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${unicode_extension_manifest_source}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.unicode\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${late_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.late\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${post_freeze_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.post-freeze\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${conflicting_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.session\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${incompatible_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.incompatible\", \"library\": \"$\", \"prefix\": \"MISSING\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${malformed_extension_manifest}" + CONTENT "{\n") + file( + GENERATE + OUTPUT "${missing_library_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.missing-library\", \"library\": \"missing-device-library\", \"prefix\": \"MISSING\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${missing_prefix_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.missing-prefix\", \"library\": \"$\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${nul_extension_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.nul\", \"library\": \"$\\u0000alias\", \"prefix\": \"TEST_SESSION\"}\n ]\n }\n}\n" + ) + file( + GENERATE + OUTPUT "${conflicting_config_manifest}" + CONTENT + "{\n \"schema-version\": 1,\n \"qdmi\": {\n \"devices\": [\n {\"id\": \"package.bad-config\", \"library\": \"$\", \"prefix\": \"TEST_SESSION\", \"session\": {\"device-config\": {\"inline\": {}}, \"custom1\": \"raw\"}}\n ]\n }\n}\n" + ) + if(WIN32) + set(CMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE PRE_TEST) + endif() + package_add_test(mqt-core-qdmi-default-driver-extension-test MQT::CoreQDMI + test_default_driver_extension.cpp) + if(WIN32) + unset(CMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE) + endif() + target_compile_definitions( + mqt-core-qdmi-default-driver-extension-test + PRIVATE "MQT_CORE_QDMI_FAKE_CLIENT=\"$\"" + "MQT_CORE_QDMI_PACKAGE_MANIFEST=\"${extension_manifest}\"" + "MQT_CORE_QDMI_UNICODE_MANIFEST_SOURCE=\"${unicode_extension_manifest_source}\"" + "MQT_CORE_QDMI_LATE_MANIFEST=\"${late_extension_manifest}\"" + "MQT_CORE_QDMI_POST_FREEZE_MANIFEST=\"${post_freeze_extension_manifest}\"" + "MQT_CORE_QDMI_CONFLICTING_MANIFEST=\"${conflicting_extension_manifest}\"" + "MQT_CORE_QDMI_INCOMPATIBLE_MANIFEST=\"${incompatible_extension_manifest}\"" + "MQT_CORE_QDMI_MALFORMED_MANIFEST=\"${malformed_extension_manifest}\"" + "MQT_CORE_QDMI_MISSING_LIBRARY_MANIFEST=\"${missing_library_extension_manifest}\"" + "MQT_CORE_QDMI_MISSING_PREFIX_MANIFEST=\"${missing_prefix_extension_manifest}\"" + "MQT_CORE_QDMI_NUL_MANIFEST=\"${nul_extension_manifest}\"" + "MQT_CORE_QDMI_CONFLICTING_CONFIG_MANIFEST=\"${conflicting_config_manifest}\"") + add_dependencies( + mqt-core-qdmi-default-driver-extension-test mqt-core-qdmi-session-device + mqt-core-qdmi-incomplete-client mqt-core-qdmi-fake-client ${MQT_CORE_TARGET_NAME}-qdmi-driver) + mqt_copy_qdmi_runtime(mqt-core-qdmi-default-driver-extension-test) + if(WIN32) # Discover tests after the Driver and device runtime copies finish. set(CMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE PRE_TEST) diff --git a/test/qdmi/client_runtime_driver.cpp b/test/qdmi/client_runtime_driver.cpp index 0fa2f5fd20..123cfeba04 100644 --- a/test/qdmi/client_runtime_driver.cpp +++ b/test/qdmi/client_runtime_driver.cpp @@ -124,6 +124,27 @@ auto queryValue(const T& result, const size_t size, void* value, /// NOLINTBEGIN(readability-identifier-naming, readability-named-parameter) uint32_t QDMI_driver_get_client_abi_version() { return TEST_CLIENT_ABI; } +#if defined(TEST_TARGETED_EXTENSION) || \ + defined(TEST_TARGETED_INIT_FAILURE_EXTENSION) +extern "C" QDMI_DRIVER_EXPORT int +MQT_CORE_QDMI_driver_session_alloc_for_device_v1(const char* deviceId, size_t, + const char*, + QDMI_Session* session) { + if (deviceId == nullptr || session == nullptr) { + return QDMI_ERROR_INVALIDARGUMENT; + } + *session = nullptr; +#ifdef TEST_TARGETED_INIT_FAILURE_EXTENSION + return QDMI_session_alloc(session); +#else + if (deviceId != nullptr && std::string_view{deviceId} == "warning-null") { + return QDMI_WARN_GENERAL; + } + return QDMI_ERROR_OUTOFMEM; +#endif +} +#endif + #ifdef TEST_FULL_CLIENT int QDMI_session_alloc(QDMI_Session* session) { if (session == nullptr) { @@ -173,6 +194,9 @@ int QDMI_session_init(QDMI_Session session) { if (session->initialized) { return QDMI_ERROR_BADSTATE; } +#ifdef TEST_TARGETED_INIT_FAILURE_EXTENSION + return QDMI_ERROR_PERMISSIONDENIED; +#endif session->initialized = true; return QDMI_SUCCESS; } diff --git a/test/qdmi/driver/session_device.cpp b/test/qdmi/driver/session_device.cpp index e59af21d81..1eb3ec4e3d 100644 --- a/test/qdmi/driver/session_device.cpp +++ b/test/qdmi/driver/session_device.cpp @@ -13,9 +13,11 @@ #include #include #include +#include #include #include #include +#include #include struct QDMI_Child_Device_impl_d {}; @@ -39,6 +41,21 @@ struct QDMI_Device_Job_impl_d { }; namespace { +constexpr auto WARNING_MODE = "MQT_CORE_QDMI_TEST_DEVICE_WARNING"; + +[[nodiscard]] auto warningMode() -> std::string_view { + const auto* const value = std::getenv(WARNING_MODE); + return value == nullptr ? std::string_view{} : std::string_view{value}; +} + +[[nodiscard]] auto successfulStatus(const std::string_view operation) -> int { + const auto mode = warningMode(); + return mode == "all" || mode == operation || + (mode == "children-null" && operation == "children") + ? QDMI_WARN_GENERAL + : QDMI_SUCCESS; +} + [[nodiscard]] auto activeSessions() -> std::atomic_size_t& { static std::atomic_size_t sessions = 0; return sessions; @@ -151,7 +168,13 @@ auto queryValue(const T& result, const size_t size, void* value, // QDMI requires these exported C symbols to use the configured device prefix. // NOLINTBEGIN(readability-identifier-naming) -extern "C" int TEST_SESSION_QDMI_device_initialize() { return QDMI_SUCCESS; } +extern "C" int TEST_SESSION_QDMI_device_initialize() { + if (const auto* status = std::getenv("MQT_CORE_QDMI_TEST_DEVICE_INIT_STATUS"); + status != nullptr && status == std::string_view{"permission-denied"}) { + return QDMI_ERROR_PERMISSIONDENIED; + } + return successfulStatus("initialize"); +} extern "C" int TEST_SESSION_QDMI_device_finalize() { return QDMI_SUCCESS; } @@ -160,6 +183,11 @@ TEST_SESSION_QDMI_device_session_alloc(QDMI_Device_Session* session) { if (session == nullptr) { return QDMI_ERROR_INVALIDARGUMENT; } + const auto mode = warningMode(); + if (mode == "alloc-null") { + *session = nullptr; + return QDMI_WARN_GENERAL; + } // The QDMI C API transfers this allocation through an opaque raw handle. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) *session = new (std::nothrow) QDMI_Device_Session_impl_d; @@ -167,7 +195,10 @@ TEST_SESSION_QDMI_device_session_alloc(QDMI_Device_Session* session) { return QDMI_ERROR_OUTOFMEM; } ++activeSessions(); - return QDMI_SUCCESS; + if (mode == "alloc-error-handle") { + return QDMI_ERROR_PERMISSIONDENIED; + } + return successfulStatus("alloc"); } extern "C" int TEST_SESSION_QDMI_device_session_set_parameter( @@ -189,12 +220,12 @@ extern "C" int TEST_SESSION_QDMI_device_session_set_parameter( return QDMI_ERROR_INVALIDARGUMENT; } session->child = child; - return QDMI_SUCCESS; + return successfulStatus("set"); } if (value != nullptr) { session->parameters[param] = static_cast(value); } - return QDMI_SUCCESS; + return successfulStatus("set"); } extern "C" int @@ -206,7 +237,7 @@ TEST_SESSION_QDMI_device_session_init(QDMI_Device_Session session) { return QDMI_ERROR_BADSTATE; } session->initialized = true; - return QDMI_SUCCESS; + return successfulStatus("init"); } extern "C" void @@ -227,6 +258,10 @@ extern "C" int TEST_SESSION_QDMI_device_session_query_device_property( return QDMI_ERROR_BADSTATE; } if (prop == QDMI_DEVICE_PROPERTY_CHILDDEVICES) { + if (parameter(session, QDMI_DEVICE_SESSION_PARAMETER_CUSTOM5) == + "permission-denied") { + return QDMI_ERROR_PERMISSIONDENIED; + } if (session->child != nullptr || parameter(session, QDMI_DEVICE_SESSION_PARAMETER_CUSTOM5) != "with-child") { @@ -237,15 +272,18 @@ extern "C" int TEST_SESSION_QDMI_device_session_query_device_property( *sizeRet = required; } if (value == nullptr) { - return QDMI_SUCCESS; + return successfulStatus("children"); } if (size < required) { return QDMI_ERROR_INVALIDARGUMENT; } + if (warningMode() == "children-null") { + return QDMI_WARN_GENERAL; + } const auto* const child = childDeviceHandle(); std::memcpy(value, static_cast(&child), sizeof(QDMI_Child_Device)); - return QDMI_SUCCESS; + return successfulStatus("children"); } if (prop == QDMI_DEVICE_PROPERTY_CUSTOM1) { const auto& operations = customOperationHandles(); diff --git a/test/qdmi/driver/test_driver.cpp b/test/qdmi/driver/test_driver.cpp index 637c7ee56e..12d929543a 100644 --- a/test/qdmi/driver/test_driver.cpp +++ b/test/qdmi/driver/test_driver.cpp @@ -38,6 +38,14 @@ #include #include +/// The private Driver ABI fixes these exported symbol names. +/// NOLINTBEGIN(readability-identifier-naming) +extern "C" int MQT_CORE_QDMI_driver_add_manifest_v1(const char* manifestPath); +extern "C" int MQT_CORE_QDMI_driver_session_alloc_for_device_v1( + const char* deviceId, size_t deviceSessionJsonSize, + const char* deviceSessionJson, QDMI_Session* session); +/// NOLINTEND(readability-identifier-naming) + namespace testing { namespace { auto stringConcat5(const std::string& a, const std::string& b, @@ -398,7 +406,7 @@ TEST(ChildDeviceTest, CleansUpWhenSelectingAChildFails) { TEST(ChildDeviceTest, RejectsMalformedChildLists) { const auto library = std::make_shared(); library->malformedChildList = true; - EXPECT_THROW(QDMI_Device_impl_d{library}, std::runtime_error); + EXPECT_THROW(QDMI_Device_impl_d{library}, std::invalid_argument); EXPECT_EQ(library->allocatedSessions, 1); EXPECT_EQ(library->freedSessions, 1); } @@ -923,6 +931,36 @@ TEST(ConfiguredDriverTest, ExposesWorkingDefinitionsAndIsolatesFailures) { QDMI_session_free(session); } +TEST(ConfiguredDriverTest, + ExtensionAbiValidatesArgumentsAndPropagatesDeviceStatus) { + EXPECT_EQ(MQT_CORE_QDMI_driver_add_manifest_v1(nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_CORE_QDMI_driver_add_manifest_v1(""), + QDMI_ERROR_INVALIDARGUMENT); + + QDMI_Session session = nullptr; + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1( + "test.session-overrides", 0, nullptr, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1(nullptr, 0, + nullptr, &session), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1("", 0, nullptr, + &session), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1( + "test.session-overrides", 1, nullptr, &session), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1("test.disabled", 0, + nullptr, &session), + QDMI_ERROR_PERMISSIONDENIED); + EXPECT_EQ(session, nullptr); + EXPECT_EQ(MQT_CORE_QDMI_driver_session_alloc_for_device_v1( + "test.unknown-target", 0, nullptr, &session), + QDMI_ERROR_NOTFOUND); + EXPECT_EQ(session, nullptr); +} + TEST(DeviceRegistrationTest, ValidatesDuplicatesAndReplacement) { auto& driver = qdmi::Driver::get(); EXPECT_THROW(driver.registerDevice({}), std::invalid_argument); @@ -938,6 +976,15 @@ TEST(DeviceRegistrationTest, ValidatesDuplicatesAndReplacement) { invalidId.id = std::string{"test.alias\0hidden", 17}; EXPECT_THROW(driver.registerDevice(std::move(invalidId)), std::invalid_argument); + auto invalidLibrary = original; + invalidLibrary.library = + std::filesystem::path{std::string{"device\0alias", 12}}; + EXPECT_THROW(driver.registerDevice(std::move(invalidLibrary)), + std::invalid_argument); + auto invalidPrefix = original; + invalidPrefix.prefix.clear(); + EXPECT_THROW(driver.registerDevice(std::move(invalidPrefix)), + std::invalid_argument); driver.registerDevice(original); EXPECT_THROW(driver.registerDevice(original), std::invalid_argument); diff --git a/test/qdmi/registry/test_device_registry.cpp b/test/qdmi/registry/test_device_registry.cpp index 12297a08e5..4750c25c48 100644 --- a/test/qdmi/registry/test_device_registry.cpp +++ b/test/qdmi/registry/test_device_registry.cpp @@ -13,6 +13,7 @@ #include "qdmi/driver/Driver.hpp" #include +#include #include #include @@ -138,18 +139,37 @@ TEST(DeviceRegistry, RejectsDuplicateIdsAndUnsupportedKeys) { } } -TEST(DeviceRegistry, RejectsIdsWithEmbeddedNul) { +TEST(DeviceRegistry, RejectsInvalidCStringAndPathFields) { const TemporaryDirectory directory; const auto configFile = emptyConfig(directory); - const ScopedEnvironmentVariable configJson("MQT_CORE_QDMI_CONFIG_JSON", R"({ - "schema-version": 1, - "qdmi": {"devices": [{ - "id": "test.alias\u0000hidden", "library": "device", "prefix": "TEST" - }]} - })"); + for ( + const auto* document : { + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.alias\u0000hidden","library":"device","prefix":"TEST"}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.library","library":"device\u0000alias","prefix":"TEST"}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.prefix","library":"device","prefix":"TEST\u0000ALIAS"}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.auth","library":"device","prefix":"TEST","session":{"auth-file":"auth\u0000alias"}}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.config","library":"device","prefix":"TEST","session":{"device-config":{"file":"config\u0000alias"}}}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.empty-library","library":"","prefix":"TEST"}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.empty-auth","library":"device","prefix":"TEST","session":{"auth-file":""}}]}})", + R"({"schema-version":1,"qdmi":{"devices":[{"id":"test.conflict","library":"device","prefix":"TEST","session":{"device-config":{"inline":{}},"custom1":"raw"}}]}})", + }) { + SCOPED_TRACE(document); + const ScopedEnvironmentVariable configJson("MQT_CORE_QDMI_CONFIG_JSON", + document); + EXPECT_THROW(static_cast(qdmi::detail::DeviceRegistry()), + std::invalid_argument); + } +} + +TEST(DeviceRegistry, PreservesEmbeddedNullInLengthDelimitedSessionValues) { + constexpr std::string_view json = R"({"custom3":"x\u0000y"})"; + qdmi::DeviceSessionConfig config; - EXPECT_THROW(static_cast(qdmi::detail::DeviceRegistry()), - std::invalid_argument); + ASSERT_EQ( + qdmi::detail::parseDeviceSessionJson(json.data(), json.size(), config), + QDMI_SUCCESS); + ASSERT_TRUE(config.custom3.has_value()); + EXPECT_EQ(*config.custom3, std::string("x\0y", 3)); } TEST(DeviceRegistry, MergesEnvironmentJsonOverExplicitFile) { diff --git a/test/qdmi/test_client_runtime.cpp b/test/qdmi/test_client_runtime.cpp index 09b896d680..cfc9cf255c 100644 --- a/test/qdmi/test_client_runtime.cpp +++ b/test/qdmi/test_client_runtime.cpp @@ -39,6 +39,40 @@ void setDriverEnvironment(const std::optional& value) { } TEST(ClientRuntimeTest, ValidatesThenFreezesOneDriverAndRetainsSessions) { + const std::string nulPath{"driver\0alias", 12}; + EXPECT_THROW(static_cast(Session{SessionConfig{ + .driverPath = std::filesystem::path{nulPath}}}), + std::invalid_argument); + EXPECT_THROW(default_driver::addManifest(std::filesystem::path{nulPath}), + std::invalid_argument); + + default_driver::addManifest(MQT_CORE_QDMI_RUNTIME_EXTENSION_MANIFEST); + EXPECT_THAT( + [] { + return default_driver::openDevice( + "unused", {}, + std::optional{MQT_CORE_QDMI_TEST_DRIVER}); + }, + testing::ThrowsMessage( + testing::HasSubstr("does not support targeted sessions"))); + EXPECT_THROW(static_cast(default_driver::openDevice( + "unused", {}, + std::optional{ + MQT_CORE_QDMI_TARGETED_FAILURE_DRIVER})), + std::bad_alloc); + testing::internal::CaptureStderr(); + EXPECT_THAT( + [] { + return default_driver::openDevice( + "warning-null", {}, + std::optional{ + MQT_CORE_QDMI_TARGETED_FAILURE_DRIVER}); + }, + testing::ThrowsMessage( + testing::HasSubstr("returned a null session"))); + EXPECT_THAT(testing::internal::GetCapturedStderr(), + testing::Not(testing::HasSubstr("Warning: Allocating"))); + const auto missing = std::filesystem::path(MQT_CORE_QDMI_TEST_DRIVER).parent_path() / "missing-client-driver"; @@ -91,6 +125,23 @@ TEST(ClientRuntimeTest, ValidatesThenFreezesOneDriverAndRetainsSessions) { testing::HasSubstr("missing symbol QDMI_session_alloc"))); Session first(firstConfig); + EXPECT_THAT( + [] { + default_driver::addManifest(MQT_CORE_QDMI_RUNTIME_EXTENSION_MANIFEST); + }, + testing::ThrowsMessage( + testing::HasSubstr("does not support package manifests"))); + EXPECT_THAT([] { return default_driver::openDevice("unused"); }, + testing::ThrowsMessage( + testing::HasSubstr("does not support targeted sessions"))); + EXPECT_THAT( + [] { + return default_driver::openDevice("unused", {}, + std::optional{ + MQT_CORE_QDMI_INCOMPLETE_DRIVER}); + }, + testing::ThrowsMessage( + testing::HasSubstr("already selected"))); { const mqt::test::ScopedEnvironmentVariable nullAllocation{ "MQT_CORE_QDMI_FAKE_FAIL_ALLOCATION", "success-null"}; diff --git a/test/qdmi/test_client_targeted_selection.cpp b/test/qdmi/test_client_targeted_selection.cpp new file mode 100644 index 0000000000..4646f80ab4 --- /dev/null +++ b/test/qdmi/test_client_targeted_selection.cpp @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 - 2026 Chair for Design Automation, TUM + * Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH + * All rights reserved. + * + * SPDX-License-Identifier: MIT + * + * Licensed under the MIT License + */ + +#include "qdmi/Client.hpp" + +#include +#include + +#include +#include +#include + +namespace qdmi { +namespace { + +TEST(ClientTargetedSelectionTest, SuccessfulRawAllocationFixesSelection) { + EXPECT_THAT( + [] { + return default_driver::openDevice( + "unused", {}, + std::optional{ + MQT_CORE_QDMI_TARGETED_INIT_FAILURE_DRIVER}); + }, + testing::ThrowsMessage( + testing::HasSubstr("Permission denied"))); + EXPECT_THAT( + [] { + return Session{SessionConfig{.driverPath = MQT_CORE_QDMI_TEST_DRIVER}}; + }, + testing::ThrowsMessage( + testing::HasSubstr("already selected"))); +} + +} // namespace +} // namespace qdmi diff --git a/test/qdmi/test_default_driver_extension.cpp b/test/qdmi/test_default_driver_extension.cpp new file mode 100644 index 0000000000..cdb4dd45e3 --- /dev/null +++ b/test/qdmi/test_default_driver_extension.cpp @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2023 - 2026 Chair for Design Automation, TUM + * Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH + * All rights reserved. + * + * SPDX-License-Identifier: MIT + * + * Licensed under the MIT License + */ + +#include "qdmi/Client.hpp" +#include "qdmi/common/Common.hpp" + +#include +#include +#include +/// POSIX declares setenv and unsetenv in this compatibility header. +/// NOLINTNEXTLINE(modernize-deprecated-headers) +#include + +#include +#include +#include +#include +#include + +namespace qdmi { +namespace { + +void setEnvironment(const char* const name, const std::string_view value) { +#ifdef _WIN32 + ASSERT_EQ(_putenv_s(name, std::string(value).c_str()), 0); +#else + if (value.empty()) { + ASSERT_EQ(unsetenv(name), 0); + } else { + ASSERT_EQ(setenv(name, std::string(value).c_str(), 1), 0); + } +#endif +} + +void setConfigurationJson(const std::string_view value) { + setEnvironment("MQT_CORE_QDMI_CONFIG_JSON", value); +} + +TEST(DefaultDriverExtensionTest, StagesThenOpensStrictFreshSessions) { + EXPECT_THAT([] { default_driver::addManifest("missing-package-manifest"); }, + testing::ThrowsMessage( + testing::HasSubstr("Library not found"))); + EXPECT_THAT( + [] { default_driver::addManifest(MQT_CORE_QDMI_MALFORMED_MANIFEST); }, + testing::ThrowsMessage( + testing::HasSubstr("Invalid argument"))); + EXPECT_THAT( + [] { + default_driver::addManifest(MQT_CORE_QDMI_MISSING_LIBRARY_MANIFEST); + }, + testing::ThrowsMessage( + testing::HasSubstr("Library not found"))); + EXPECT_THROW( + default_driver::addManifest(MQT_CORE_QDMI_MISSING_PREFIX_MANIFEST), + std::invalid_argument); + EXPECT_THROW(default_driver::addManifest(MQT_CORE_QDMI_NUL_MANIFEST), + std::invalid_argument); + EXPECT_THROW( + default_driver::addManifest(MQT_CORE_QDMI_CONFLICTING_CONFIG_MANIFEST), + std::invalid_argument); + + setEnvironment("MQT_CORE_QDMI_DRIVER", MQT_CORE_QDMI_FAKE_CLIENT); + default_driver::addManifest(MQT_CORE_QDMI_PACKAGE_MANIFEST); + setEnvironment("MQT_CORE_QDMI_DRIVER", {}); + default_driver::addManifest(MQT_CORE_QDMI_PACKAGE_MANIFEST); + EXPECT_THROW(default_driver::addManifest(MQT_CORE_QDMI_CONFLICTING_MANIFEST), + std::invalid_argument); + + constexpr std::string_view unicodeFilename = "package-\xC3\xBC" + "nicode.qdmi.json"; + const auto unicodeManifestSource = + detail::pathFromUtf8(MQT_CORE_QDMI_UNICODE_MANIFEST_SOURCE); + const auto unicodeManifest = unicodeManifestSource.parent_path() / + detail::pathFromUtf8(unicodeFilename); + EXPECT_EQ(detail::pathToUtf8(unicodeManifest.filename()), unicodeFilename); + ASSERT_TRUE(std::filesystem::copy_file( + unicodeManifestSource, unicodeManifest, + std::filesystem::copy_options::overwrite_existing)); + default_driver::addManifest(unicodeManifest); + + setConfigurationJson("{"); + EXPECT_THROW(static_cast(default_driver::openDevice("package.session")), + std::invalid_argument); + setConfigurationJson({}); + default_driver::addManifest(MQT_CORE_QDMI_LATE_MANIFEST); + default_driver::addManifest(MQT_CORE_QDMI_INCOMPATIBLE_MANIFEST); + + setConfigurationJson( + R"({"schema-version":1,"qdmi":{"devices":[{"id":"package.session","session":{"custom4":"busy","custom5":"with-child"}}]}})"); + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", "alloc-error-handle"); + EXPECT_THAT([] { return default_driver::openDevice("package.session"); }, + testing::ThrowsMessage( + testing::HasSubstr("Permission denied"))); + + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", "alloc-null"); + testing::internal::CaptureStdout(); + testing::internal::CaptureStderr(); + EXPECT_THAT([] { return default_driver::openDevice("package.session"); }, + testing::ThrowsMessage( + testing::HasSubstr("A fatal error"))); + const auto warningOutput = testing::internal::GetCapturedStdout() + + testing::internal::GetCapturedStderr(); + EXPECT_THAT(warningOutput, + testing::Not(testing::HasSubstr("general warning"))); + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", {}); + EXPECT_THAT( + [] { + return default_driver::openDevice( + "unused", {}, + std::optional{MQT_CORE_QDMI_FAKE_CLIENT}); + }, + testing::ThrowsMessage( + testing::HasSubstr("does not support targeted sessions"))); + + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", "all"); + setEnvironment("MQT_CORE_QDMI_DRIVER", MQT_CORE_QDMI_FAKE_CLIENT); + const auto first = default_driver::openDevice("package.session"); + setConfigurationJson({}); + setEnvironment("MQT_CORE_QDMI_DRIVER", {}); + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", {}); + EXPECT_EQ(first.getId(), "package.session"); + EXPECT_THAT(first.getName(), testing::HasSubstr("active=2")); + EXPECT_EQ(first.getStatus(), QDMI_DEVICE_STATUS_BUSY); + EXPECT_EQ(first.getChildDevices().size(), 1U); + + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", "children-null"); + EXPECT_THROW(static_cast(default_driver::openDevice("package.session")), + std::invalid_argument); + setEnvironment("MQT_CORE_QDMI_TEST_DEVICE_WARNING", {}); + + const auto second = + default_driver::openDevice("package.session", R"({"custom4":"offline"})"); + EXPECT_NE(first, second); + EXPECT_EQ(second.getStatus(), QDMI_DEVICE_STATUS_OFFLINE); + + EXPECT_THROW( + static_cast(default_driver::openDevice("package.session", "{")), + std::invalid_argument); + EXPECT_THROW(static_cast(default_driver::openDevice( + "package.session", + R"({"device-config":{"inline":{}},"custom1":"raw"})")), + std::invalid_argument); + const std::string nullId{"package.session\0alias", 21}; + EXPECT_THROW(static_cast(default_driver::openDevice(nullId)), + std::invalid_argument); + + EXPECT_THAT([] { return default_driver::openDevice("package.child-error"); }, + testing::ThrowsMessage( + testing::HasSubstr("Permission denied"))); + EXPECT_THAT([] { return default_driver::openDevice("package.incompatible"); }, + testing::ThrowsMessage( + testing::HasSubstr("A fatal error"))); + EXPECT_EQ(default_driver::openDevice("package.unicode").getId(), + "package.unicode"); + EXPECT_TRUE(std::filesystem::remove(unicodeManifest)); + + default_driver::addManifest(unicodeManifest); + default_driver::addManifest(MQT_CORE_QDMI_PACKAGE_MANIFEST); + EXPECT_THAT( + [] { default_driver::addManifest(MQT_CORE_QDMI_POST_FREEZE_MANIFEST); }, + testing::ThrowsMessage( + testing::HasSubstr("Bad state"))); +} + +} // namespace +} // namespace qdmi diff --git a/test/qdmi/test_packaged_runtime.cpp b/test/qdmi/test_packaged_runtime.cpp index c0312cd2af..d8f8fa7a1d 100644 --- a/test/qdmi/test_packaged_runtime.cpp +++ b/test/qdmi/test_packaged_runtime.cpp @@ -32,6 +32,13 @@ int main(const int argc, const char* const argv[]) { return EXIT_FAILURE; } std::filesystem::current_path(std::filesystem::temp_directory_path()); + { + const auto targeted = + qdmi::default_driver::openDevice("mqt.ddsim.default"); + if (targeted.getId() != "mqt.ddsim.default") { + return EXIT_FAILURE; + } + } qdmi::Session session; return session.getDevices().empty() ? EXIT_FAILURE : EXIT_SUCCESS; } catch (const std::exception& error) {