Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog.d/831-rust-core-loader-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 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.
- 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.
14 changes: 13 additions & 1 deletion docs/doctoring/automatic_backend_rust_authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 21 additions & 6 deletions python/fast_mlsirm/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,7 +65,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
44 changes: 44 additions & 0 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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 == []
60 changes: 60 additions & 0 deletions tests/test_backend_import_failure_contract.py
Original file line number Diff line number Diff line change
@@ -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
Loading