From 5af0f3688a54c9746378a7a5c0ebc57cf01e7dde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 07:33:08 +0900 Subject: [PATCH 1/7] fix(backend): normalize native core loader failures --- python/fast_mlsirm/backend.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/python/fast_mlsirm/backend.py b/python/fast_mlsirm/backend.py index d38eab8e1..9ba9d0f58 100644 --- a/python/fast_mlsirm/backend.py +++ b/python/fast_mlsirm/backend.py @@ -61,7 +61,18 @@ def load_rust_core() -> ModuleType: def _load_core() -> ModuleType | None: - """Import ``fast_mlsirm._core`` if it is installed, else return ``None``.""" + """Import the Rust core or normalize native loader failures. + + A missing extension is represented as ``None`` so the public resolver can + retain its existing missing-core messages. If discovery succeeds but the + native module cannot be loaded, fail closed with a package-owned error and + preserve the loader exception as the cause for operator diagnostics. + """ if importlib.util.find_spec(CORE_MODULE) is None: return None - return importlib.import_module(CORE_MODULE) + try: + return importlib.import_module(CORE_MODULE) + except (ImportError, OSError) as exc: + raise RuntimeError( + "compiled Rust core is present but could not be imported" + ) from exc From d98618b80b310cb5f7497f2bf66af6e7eced93b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:10:25 +0900 Subject: [PATCH 2/7] test(backend): cover native core loader failures --- tests/test_backend_import_failure_contract.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_backend_import_failure_contract.py diff --git a/tests/test_backend_import_failure_contract.py b/tests/test_backend_import_failure_contract.py new file mode 100644 index 000000000..13fb58916 --- /dev/null +++ b/tests/test_backend_import_failure_contract.py @@ -0,0 +1,60 @@ +"""Fail-closed contracts for an installed but unloadable Rust extension.""" + +from __future__ import annotations + +import importlib.machinery +from collections.abc import Iterator + +import pytest + +import fast_mlsirm.backend as backend + + +@pytest.fixture(params=[ImportError, OSError]) +def _installed_core_that_fails_to_import( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +) -> Iterator[ImportError | OSError]: + """Make discovery succeed while the compiled extension import fails.""" + spec = importlib.machinery.ModuleSpec(backend.CORE_MODULE, loader=None) + monkeypatch.setattr(backend.importlib.util, "find_spec", lambda _name: spec) + exception_type = request.param + failure = exception_type("dlopen failed: incompatible compiled extension") + + def fail_import(_name: str): + raise failure + + monkeypatch.setattr(backend.importlib, "import_module", fail_import) + yield failure + + +@pytest.mark.parametrize("requested", ["auto", "rust"]) +def test_resolve_backend_normalizes_compiled_core_import_failure( + _installed_core_that_fails_to_import: ImportError | OSError, + requested: str, +) -> None: + """Backend resolution must fail closed with a package-owned RuntimeError.""" + failure = _installed_core_that_fails_to_import + + with pytest.raises( + RuntimeError, + match="compiled Rust core is present but could not be imported", + ) as caught: + backend.resolve_backend(requested) + + assert caught.value.__cause__ is failure + + +def test_load_rust_core_normalizes_compiled_core_import_failure( + _installed_core_that_fails_to_import: ImportError | OSError, +) -> None: + """Direct core loading must expose the same stable fail-closed contract.""" + failure = _installed_core_that_fails_to_import + + with pytest.raises( + RuntimeError, + match="compiled Rust core is present but could not be imported", + ) as caught: + backend.load_rust_core() + + assert caught.value.__cause__ is failure \ No newline at end of file From 93ef442dbb7010b92c3e959c90ebb2b8f944e3ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:10:15 +0900 Subject: [PATCH 3/7] docs(changelog): record fail-closed Rust loader handling --- docs/changelog.d/831-rust-core-loader-fail-closed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog.d/831-rust-core-loader-fail-closed.md diff --git a/docs/changelog.d/831-rust-core-loader-fail-closed.md b/docs/changelog.d/831-rust-core-loader-fail-closed.md new file mode 100644 index 000000000..c5c4a2141 --- /dev/null +++ b/docs/changelog.d/831-rust-core-loader-fail-closed.md @@ -0,0 +1,5 @@ +# Fail-closed compiled Rust loader handling + +## Fixed + +- Normalize a discoverable but unloadable compiled Rust core to a package-owned runtime error while preserving the original loader exception as its cause. From 17843d8b4ea6e866ac73a56732a4eb9cda7e4876 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:38:49 +0900 Subject: [PATCH 4/7] test(backend): reject control conversion callbacks --- tests/test_backend.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_backend.py b/tests/test_backend.py index b77106af4..65b5fedc5 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -3,6 +3,23 @@ from fast_mlsirm import backend +class _HostileControlName: + """Object whose string conversion must never run at a control boundary.""" + + def __str__(self) -> str: + raise AssertionError("caller-controlled __str__ executed") + + +class _HostileControlString(str): + """String subclass whose normalization callbacks must never run.""" + + def __str__(self) -> str: + raise AssertionError("caller-controlled str-subclass __str__ executed") + + def strip(self, chars=None) -> str: + raise AssertionError("caller-controlled str-subclass strip executed") + + def test_load_core_surfaces_import_errors(monkeypatch): monkeypatch.setattr(backend.importlib.util, "find_spec", lambda name: object()) @@ -43,3 +60,30 @@ def unexpected_core_load(): assert backend.resolve_backend("numpy") == "numpy" assert calls == [] + + +def test_backend_normalizers_reject_untrusted_control_types_before_callbacks() -> None: + """Backend/device controls must not invoke caller-defined string callbacks.""" + for normalizer, label in ( + (backend.normalize_backend, "backend"), + (backend.normalize_device, "rust_device"), + ): + for value in (_HostileControlName(), _HostileControlString("auto")): + with pytest.raises(ValueError, match=rf"{label} must be an exact built-in string"): + normalizer(value) + + +def test_resolve_backend_rejects_untrusted_name_before_core_access(monkeypatch) -> None: + """Public resolution must reject an untrusted control before native discovery.""" + core_loads: list[None] = [] + + def unexpected_core_load(): + core_loads.append(None) + raise AssertionError("native discovery must not run for an invalid backend control") + + monkeypatch.setattr(backend, "_load_core", unexpected_core_load) + + with pytest.raises(ValueError, match="backend must be an exact built-in string"): + backend.resolve_backend(_HostileControlName()) + + assert core_loads == [] From 8c9713a2e3f1808899b91090fba6f22bd1975a15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:39:19 +0900 Subject: [PATCH 5/7] fix(backend): reject control conversion callbacks --- python/fast_mlsirm/backend.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/python/fast_mlsirm/backend.py b/python/fast_mlsirm/backend.py index 9ba9d0f58..dcd549b17 100644 --- a/python/fast_mlsirm/backend.py +++ b/python/fast_mlsirm/backend.py @@ -15,16 +15,20 @@ def normalize_backend(name: str) -> str: - """Lower-case and validate a backend name against ``{numpy, rust, auto}``.""" - backend = str(name).strip().lower() + """Lower-case and validate an exact built-in backend control string.""" + if type(name) is not str: + raise ValueError("backend must be an exact built-in string") + backend = name.strip().lower() if backend not in VALID_BACKENDS: raise ValueError(f"backend must be one of {sorted(VALID_BACKENDS)}") return backend def normalize_device(name: str) -> str: - """Lower-case and validate a Rust-backend device against ``{cpu, gpu, auto}``.""" - device = str(name).strip().lower() + """Lower-case and validate an exact built-in Rust-device control string.""" + if type(name) is not str: + raise ValueError("rust_device must be an exact built-in string") + device = name.strip().lower() if device not in VALID_DEVICES: raise ValueError(f"rust_device must be one of {sorted(VALID_DEVICES)}") return device From 80da63a257ab0cdcdcf0fba8f6f6ee76ab2c8dbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:40:34 +0900 Subject: [PATCH 6/7] docs(backend): record strict control validation --- docs/doctoring/automatic_backend_rust_authority.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/automatic_backend_rust_authority.md b/docs/doctoring/automatic_backend_rust_authority.md index 3354404fe..9927c73d6 100644 --- a/docs/doctoring/automatic_backend_rust_authority.md +++ b/docs/doctoring/automatic_backend_rust_authority.md @@ -12,12 +12,24 @@ Python's import machinery provides a direct capability probe: `importlib.util.fi The package exposes a stable, non-reflective error for an unavailable automatic production backend rather than reflecting local paths, ABI details, environment data, or import exception text into the fallback decision. Actual import/ABI failures remain diagnostic failures rather than being reclassified as permission to run NumPy arithmetic. +Backend and Rust-device names are control-plane values, not general string-convertible data. Calling `str()` or overridden string methods on an arbitrary object can execute caller-defined Python before the allowlist decision. The public normalizers therefore accept exact built-in `str` instances only, then perform package-owned whitespace/case normalization and positive allowlist validation. Non-strings and `str` subclasses fail before conversion, native-core discovery, or any caller callback. This is a type-validation boundary consistent with CWE-1287 and OWASP ASVS 5.0 input-validation guidance; it changes no numerical formula or backend ownership. + +NIST SP 800-218 Version 1.1 remains the final SSDF authority. NIST SP 800-218 Rev. 1 Version 1.2 is an Initial Public Draft as of this doctoring update and is tracked as newer draft guidance rather than represented as final. + ## Falsification and acceptance -This decision is falsified if any ordinary `backend="auto"` call can select NumPy because the Rust extension is missing or incompatible. Acceptance requires tests proving that automatic resolution selects Rust when available, fails closed when absent, and explicit NumPy resolution remains an explicit caller decision. Installed-wheel/package evidence must continue to prove the Rust extension is present in supported production artifacts. +This decision is falsified if any ordinary `backend="auto"` call can select NumPy because the Rust extension is missing or incompatible. It is also falsified if backend/device validation executes caller-defined string-conversion or normalization callbacks before rejecting an invalid control value. Acceptance requires tests proving that automatic resolution selects Rust when available, fails closed when absent, explicit NumPy resolution remains an explicit caller decision, and malformed control objects fail before native discovery. Installed-wheel/package evidence must continue to prove the Rust extension is present in supported production artifacts. ## References +MITRE. (2026, April 30). *CWE-1287: Improper validation of specified type of input (Version 4.20)*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/1287.html + +Open Worldwide Application Security Project. (n.d.). *Application Security Verification Standard 5.0: V2.2 input validation*. Retrieved August 14, 2026, from https://cornucopia.owasp.org/taxonomy/asvs-5.0/02-validation-and-business-logic/02-input-validation + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure Software Development Framework (SSDF) Version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd + Python Software Foundation. (n.d.). *importlib — The implementation of import*. Python 3.14.6 documentation. Retrieved August 10, 2026, from https://docs.python.org/3/library/importlib.html PyO3 Project. (n.d.). *Building and distribution*. PyO3 user guide. Retrieved August 10, 2026, from https://pyo3.rs/main/building-and-distribution From a52de8a80acc67409a5cab2d0d4c998239798eaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:40:58 +0900 Subject: [PATCH 7/7] docs(changelog): record backend control hardening --- docs/changelog.d/831-rust-core-loader-fail-closed.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.d/831-rust-core-loader-fail-closed.md b/docs/changelog.d/831-rust-core-loader-fail-closed.md index c5c4a2141..46a2b7f86 100644 --- a/docs/changelog.d/831-rust-core-loader-fail-closed.md +++ b/docs/changelog.d/831-rust-core-loader-fail-closed.md @@ -3,3 +3,4 @@ ## Fixed - Normalize a discoverable but unloadable compiled Rust core to a package-owned runtime error while preserving the original loader exception as its cause. +- Reject non-string and `str`-subclass backend/device control values before caller-defined conversion or normalization callbacks can execute, while preserving case/whitespace normalization for exact built-in strings.