diff --git a/matlab/tests/run_matlab_tests.py b/matlab/tests/run_matlab_tests.py index 5c034d0..0b6ed83 100644 --- a/matlab/tests/run_matlab_tests.py +++ b/matlab/tests/run_matlab_tests.py @@ -21,6 +21,8 @@ "jitter": "run_jitter_load", } +_WINDOWS_SCRIPT_SUFFIXES = {".bat", ".cmd"} + def _repo_root() -> Path: return Path(__file__).resolve().parents[2] @@ -52,8 +54,30 @@ def _standard_install_candidates() -> list[Path]: return candidates +def _has_windows_pe_signature(path: Path) -> bool: + try: + with path.open("rb") as handle: + dos_header = handle.read(64) + if len(dos_header) < 64 or dos_header[:2] != b"MZ": + return False + pe_offset = int.from_bytes(dos_header[0x3C:0x40], byteorder="little") + if pe_offset < 64: + return False + handle.seek(pe_offset) + return handle.read(4) == b"PE\0\0" + except OSError: + return False + + def _is_executable_file(path: Path) -> bool: - return path.is_file() and os.access(path, os.X_OK) + if not path.is_file(): + return False + if os.name == "nt": + suffix = path.suffix.lower() + if suffix in _WINDOWS_SCRIPT_SUFFIXES: + return True + return suffix == ".exe" and _has_windows_pe_signature(path) + return os.access(path, os.X_OK) def _resolve_matlab_candidate(candidate: str) -> Path | None: @@ -63,7 +87,9 @@ def _resolve_matlab_candidate(candidate: str) -> Path | None: resolved = shutil.which(candidate) if resolved: - return Path(resolved) + resolved_path = Path(resolved) + if _is_executable_file(resolved_path): + return resolved_path return None @@ -162,7 +188,11 @@ def main(argv: list[str] | None = None) -> int: if args.dry_run: return 0 - completed = subprocess.run(command, cwd=_repo_root(), check=False) + try: + completed = subprocess.run(command, cwd=_repo_root(), check=False) + except OSError as exc: + print(f"Failed to execute MATLAB executable {executable}: {exc}", file=sys.stderr) + return INVALID_MATLAB_EXECUTABLE_EXIT_CODE return completed.returncode diff --git a/python/tests/unit/test_matlab_test_runner.py b/python/tests/unit/test_matlab_test_runner.py index b65aff9..150db91 100644 --- a/python/tests/unit/test_matlab_test_runner.py +++ b/python/tests/unit/test_matlab_test_runner.py @@ -1,9 +1,16 @@ import importlib.util +import os +import shutil +import sys from pathlib import Path from types import SimpleNamespace def _make_executable(path: Path) -> Path: + if os.name == "nt": + path = path.with_suffix(".exe") + shutil.copyfile(sys.executable, path) + return path path.write_text("") path.chmod(0o755) return path @@ -69,6 +76,28 @@ def test_explicit_non_executable_matlab_path_is_rejected(tmp_path, capsys): assert "not found or is not executable" in capsys.readouterr().err +def test_explicit_invalid_windows_exe_is_rejected(monkeypatch, tmp_path, capsys): + runner = _load_runner() + monkeypatch.setattr(runner.os, "name", "nt") + executable = tmp_path / "matlab.exe" + executable.write_text("") + + exit_code = runner.main(["common", "--matlab-executable", str(executable), "--dry-run"]) + + assert exit_code == runner.INVALID_MATLAB_EXECUTABLE_EXIT_CODE + assert "not found or is not executable" in capsys.readouterr().err + + +def test_which_result_is_rechecked_before_accepting(monkeypatch, tmp_path): + runner = _load_runner() + monkeypatch.setattr(runner.os, "name", "nt") + executable = tmp_path / "matlab.exe" + executable.write_text("") + monkeypatch.setattr(runner.shutil, "which", lambda _name: str(executable)) + + assert runner.find_matlab_executable("matlab") is None + + def test_dry_run_prints_command_without_executing(tmp_path, capsys): runner = _load_runner() executable = _make_executable(tmp_path / "matlab") @@ -99,6 +128,21 @@ def fake_print(*args, **kwargs): assert "run_common" in print_calls[0][0][0] +def test_oserror_while_running_returns_invalid_executable(monkeypatch, tmp_path, capsys): + runner = _load_runner() + executable = _make_executable(tmp_path / "matlab") + monkeypatch.setattr( + runner.subprocess, + "run", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("cannot execute")), + ) + + exit_code = runner.main(["common", "--matlab-executable", str(executable)]) + + assert exit_code == runner.INVALID_MATLAB_EXECUTABLE_EXIT_CODE + assert "Failed to execute MATLAB executable" in capsys.readouterr().err + + def test_build_command_escapes_matlab_root(tmp_path): runner = _load_runner() executable = tmp_path / "matlab"