diff --git a/src/omotes_simulator_core/entities/assets/pyjnius_loader.py b/src/omotes_simulator_core/entities/assets/pyjnius_loader.py
index c75de8b1..2ce14a68 100644
--- a/src/omotes_simulator_core/entities/assets/pyjnius_loader.py
+++ b/src/omotes_simulator_core/entities/assets/pyjnius_loader.py
@@ -14,13 +14,54 @@
# along with this program. If not, see .
"""Binding to Rosim through Pyjnius."""
+import glob
+import hashlib
import logging
import os
+import tempfile
+import urllib.request
from typing import Callable
JavaClass = Callable
logger = logging.getLogger(__name__)
+# These three values describe one specific published Rosim JAR asset and must be bumped
+# together whenever a new Rosim JAR is released.
+#
+# The tag names the release whose asset is read, not the release this code ships in.
+# `upload_rosim_to_release.yml` attaches a JAR to every release, but this code does not read
+# those attachments: bumping the workflow's output alone leaves the pin serving the old JAR
+# with its checksum still matching.
+#
+# When bumping, establish the digest from a trusted build of the JAR rather than from a copy
+# downloaded through this pin. A digest taken from the downloaded file only restates that
+# file's contents and would attest to a tampered artifact just as readily as a genuine one.
+ROSIM_RELEASE_TAG = "0.0.30"
+ROSIM_JAR_NAME = "rosim-batch-1.2.0.jar"
+ROSIM_JAR_SHA256 = "8483fb9eb68608cffc3b72279b19947b2c1cfd9f936377c278ee0f3291b68547"
+
+# Derived at import; patching a constant above in a test will not change it.
+ROSIM_JAR_URL = (
+ f"https://github.com/Project-OMOTES/simulator-core/releases/download/"
+ f"{ROSIM_RELEASE_TAG}/{ROSIM_JAR_NAME}"
+)
+
+_HASH_CHUNK_SIZE = 1024 * 1024
+
+
+def _get_bin_path() -> str:
+ """Return the directory that holds the bundled JAR files."""
+ return os.path.join(os.path.dirname(__file__), "bin")
+
+
+def _sha256_of_file(file_path: str) -> str:
+ """Return the hex SHA-256 digest of a file, read in chunks to bound memory use."""
+ digest = hashlib.sha256()
+ with open(file_path, "rb") as file_handle:
+ for chunk in iter(lambda: file_handle.read(_HASH_CHUNK_SIZE), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
class PyjniusLoader:
"""Class to load Pyjnius and connect to Rosim.
@@ -42,60 +83,78 @@ def __init__(self) -> None:
This function should only be called ONCE. Do not use construct this class directly
but rather use `PyjniusLoader.get_loader`.
"""
- path = os.path.dirname(__file__)
+ bin_path = _get_bin_path()
import jnius_config # noqa
+ # Resolved through _get_bin_path so the classpath cannot drift from the folder
+ # download_rosim_jar writes into.
self.rosim_jar = self.download_rosim_jar()
- jnius_config.add_classpath(os.path.join(path, "bin/jfxrt.jar"))
- jnius_config.add_classpath(os.path.join(path, "bin", self.rosim_jar))
+ jnius_config.add_classpath(os.path.join(bin_path, "jfxrt.jar"))
+ jnius_config.add_classpath(os.path.join(bin_path, self.rosim_jar))
self.loaded_classes = {}
@staticmethod
def download_rosim_jar() -> str:
- """Download the Rosim JAR files.
-
- This function will download the required Rosim JAR files into the `bin` folder.
- It will download it from the latest release on github of the simulator-core repository.
- If there is already a rosim jar in the bin folder it will not download it and use that one.
- If you want the downloaded the latest one, simply delete your local copy.
- We have implemented this to bypass the 100 mb size limit on pypi.
- It returns the name of the downloaded JAR file.
- """
- import glob
- import urllib.request
+ """Ensure the Rosim JAR is present in the `bin` folder and return its file name.
- import requests # type: ignore[import-untyped]
+ The JAR is downloaded at runtime rather than shipped inside the wheel because it
+ exceeds the 100 MB per-file size limit on PyPI.
- # First a check if there are already jar files present.
- base_path = os.path.dirname(__file__)
- bin_path = os.path.join(base_path, "bin")
+ If any `rosim*.jar` is already present in the `bin` folder, no download happens and
+ that file is used, whatever its version. Picking up a newer Rosim JAR therefore takes
+ both steps: bump the module-level constants and delete the local copy, since either
+ one alone leaves the existing file in place.
+
+ The JAR is fetched from a pinned release asset URL, written to a temporary file in
+ the `bin` folder and verified against `ROSIM_JAR_SHA256`. Only a file with a matching
+ digest is moved into its final place, so an interrupted or corrupted download can
+ never be picked up as a valid cached JAR. A RuntimeError is raised when the digest
+ does not match.
+ """
+ bin_path = _get_bin_path()
+ # First a check if there are already jar files present.
jar_files = glob.glob(os.path.join(bin_path, "rosim*.jar"))
if jar_files:
logger.debug("Rosim JAR files already present, skipping download.")
- logger.debug(f"Using Rosim JAR file: {jar_files[0]}")
- return jar_files[0]
-
- repo = "Project-OMOTES/simulator-core"
- api_url = f"https://api.github.com/repos/{repo}/releases/latest"
-
- response = requests.get(api_url)
- release_data = response.json()
- assets = release_data["assets"]
- for asset in assets:
- if "rosim" in asset["name"]:
- # downloading the jar file from github releeases
- logger.debug("Downloading Rosim JAR files from GitHub")
- url = asset["browser_download_url"]
- jar_file = os.path.join(bin_path, asset["name"])
- response_url = urllib.request.urlretrieve(
- url,
- jar_file,
+ logger.debug("Using Rosim JAR file: %s", jar_files[0])
+ return os.path.basename(jar_files[0])
+
+ logger.debug("Downloading Rosim JAR files from GitHub")
+ os.makedirs(bin_path, exist_ok=True)
+ final_path = os.path.join(bin_path, ROSIM_JAR_NAME)
+
+ # Download next to the target so the final move is an atomic rename on the same volume.
+ # The temporary name must never match the `rosim*.jar` cache glob above: a partial
+ # download left behind by a killed process would otherwise be cached as a valid JAR.
+ temp_fd, temp_path = tempfile.mkstemp(dir=bin_path, prefix=".rosim-download-")
+ try:
+ os.close(temp_fd)
+ # The JAR keeps mkstemp's 0600 rather than the umask default a plain download
+ # would have taken, which only matters if it is written by one user and read by
+ # another. No OMOTES deployment switches user between the two, so the mode is
+ # left alone: reading the umask to widen it would mean mutating process-global
+ # state from library code.
+ urllib.request.urlretrieve(ROSIM_JAR_URL, temp_path)
+ actual_hash = _sha256_of_file(temp_path)
+ if actual_hash != ROSIM_JAR_SHA256:
+ raise RuntimeError(
+ f"Checksum mismatch for downloaded Rosim JAR from {ROSIM_JAR_URL}: "
+ f"expected SHA-256 {ROSIM_JAR_SHA256}, got {actual_hash}."
)
- if response_url is None:
- raise RuntimeError("Failed to download Rosim JAR files.")
- return str(asset["name"])
- raise RuntimeError("Failed to find Rosim JAR files in GitHub releases.")
+ os.replace(temp_path, final_path)
+ except BaseException:
+ try:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ except OSError:
+ # A failure to clean up must not replace the error that caused it, which
+ # carries the reason the download is unusable.
+ logger.warning("Failed to remove temporary download %s.", temp_path, exc_info=True)
+ raise
+
+ logger.debug("Using Rosim JAR file: %s", final_path)
+ return ROSIM_JAR_NAME
def load_class(self, classpath: str) -> JavaClass:
"""Load a Java class.
diff --git a/unit_test/entities/test_pyjnius_loader.py b/unit_test/entities/test_pyjnius_loader.py
new file mode 100644
index 00000000..0f1668ba
--- /dev/null
+++ b/unit_test/entities/test_pyjnius_loader.py
@@ -0,0 +1,295 @@
+# Copyright (c) 2023. Deltares & TNO
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Test the Rosim JAR download logic of the Pyjnius loader."""
+import fnmatch
+import glob
+import hashlib
+import os
+import tempfile
+import unittest
+from typing import Optional
+from unittest.mock import patch
+
+from omotes_simulator_core.entities.assets import pyjnius_loader
+from omotes_simulator_core.entities.assets.pyjnius_loader import PyjniusLoader
+
+PAYLOAD = b"this is not really a jar file" * 100
+PAYLOAD_SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
+
+# A git-LFS pointer stub is a few hundred bytes of text; a real JAR is over 100 MB.
+_LFS_POINTER_MAX_SIZE = 1024
+
+
+def _lfs_pointer_digest(file_path: str) -> Optional[str]:
+ """Return the sha256 oid recorded in a git-LFS pointer stub, or None for real content."""
+ if os.path.getsize(file_path) > _LFS_POINTER_MAX_SIZE:
+ return None
+ try:
+ with open(file_path, "r", encoding="utf-8") as file_handle:
+ lines = file_handle.read().splitlines()
+ except UnicodeDecodeError:
+ return None
+
+ if not lines or not lines[0].startswith("version https://git-lfs.github.com/spec/"):
+ return None
+ for line in lines:
+ if line.startswith("oid sha256:"):
+ return line.split("oid sha256:", 1)[1].strip()
+ return None
+
+
+class DownloadRosimJarTest(unittest.TestCase):
+ """Testcase for PyjniusLoader.download_rosim_jar."""
+
+ def setUp(self) -> None:
+ """Point the loader at an empty temporary bin folder."""
+ self._temp_dir = tempfile.TemporaryDirectory()
+ self.bin_dir = self._temp_dir.name
+ patcher = patch.object(pyjnius_loader, "_get_bin_path", return_value=self.bin_dir)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.addCleanup(self._temp_dir.cleanup)
+
+ def _write_payload(self, _url, target_path):
+ """Stand in for urllib.request.urlretrieve by writing the known payload."""
+ with open(target_path, "wb") as file_handle:
+ file_handle.write(PAYLOAD)
+ return target_path, None
+
+ def test_cache_hit_skips_download(self):
+ """An existing rosim JAR short-circuits the download and yields its bare name."""
+ cached_name = "rosim-test-9.9.9.jar"
+ with open(os.path.join(self.bin_dir, cached_name), "wb") as file_handle:
+ file_handle.write(b"cached")
+
+ with patch.object(pyjnius_loader.urllib.request, "urlretrieve") as fake_retrieve:
+ result = PyjniusLoader.download_rosim_jar()
+
+ self.assertEqual(result, cached_name)
+ self.assertEqual(fake_retrieve.call_count, 0)
+ self.assertEqual(sorted(os.listdir(self.bin_dir)), [cached_name])
+
+ def test_successful_download_moves_file_into_place(self):
+ """A download whose digest matches lands at the final path and leaves no temp file."""
+ with patch.object(pyjnius_loader, "ROSIM_JAR_SHA256", PAYLOAD_SHA256):
+ with patch.object(
+ pyjnius_loader.urllib.request, "urlretrieve", side_effect=self._write_payload
+ ) as fake_retrieve:
+ result = PyjniusLoader.download_rosim_jar()
+
+ self.assertEqual(result, pyjnius_loader.ROSIM_JAR_NAME)
+ self.assertEqual(fake_retrieve.call_count, 1)
+ self.assertEqual(fake_retrieve.call_args[0][0], pyjnius_loader.ROSIM_JAR_URL)
+ self.assertEqual(sorted(os.listdir(self.bin_dir)), [pyjnius_loader.ROSIM_JAR_NAME])
+ with open(os.path.join(self.bin_dir, pyjnius_loader.ROSIM_JAR_NAME), "rb") as file_handle:
+ self.assertEqual(file_handle.read(), PAYLOAD)
+
+ def test_hash_mismatch_raises_and_leaves_nothing_behind(self):
+ """A digest mismatch raises RuntimeError and no file is kept, temporary or final."""
+ wrong_hash = "0" * 64
+ with patch.object(pyjnius_loader, "ROSIM_JAR_SHA256", wrong_hash):
+ with patch.object(
+ pyjnius_loader.urllib.request, "urlretrieve", side_effect=self._write_payload
+ ):
+ with self.assertRaises(RuntimeError) as context:
+ PyjniusLoader.download_rosim_jar()
+
+ message = str(context.exception)
+ self.assertIn(wrong_hash, message)
+ self.assertIn(PAYLOAD_SHA256, message)
+ self.assertFalse(os.path.exists(os.path.join(self.bin_dir, pyjnius_loader.ROSIM_JAR_NAME)))
+ self.assertEqual(os.listdir(self.bin_dir), [])
+
+ def test_download_error_propagates_and_leaves_nothing_behind(self):
+ """A failing download propagates and removes the partial temporary file."""
+
+ def failing_retrieve(_url, target_path):
+ with open(target_path, "wb") as file_handle:
+ file_handle.write(b"partial")
+ raise OSError("connection reset")
+
+ with patch.object(
+ pyjnius_loader.urllib.request, "urlretrieve", side_effect=failing_retrieve
+ ):
+ with self.assertRaises(OSError):
+ PyjniusLoader.download_rosim_jar()
+
+ self.assertEqual(os.listdir(self.bin_dir), [])
+
+ def test_partial_download_is_never_visible_to_the_cache_glob(self):
+ """While a download is in flight its file must not match the `rosim*.jar` cache glob.
+
+ A process killed mid-download leaves the partial file behind. If that name matched the
+ glob, the next run would short-circuit on it and treat a truncated file as a valid
+ cached JAR forever, so the temporary name is load-bearing rather than cosmetic.
+ """
+ targets: list[str] = []
+
+ def retrieve_and_inspect(_url, target_path):
+ targets.append(target_path)
+ with open(target_path, "wb") as file_handle:
+ file_handle.write(b"partial")
+ raise OSError("killed mid-download")
+
+ with patch.object(
+ pyjnius_loader.urllib.request, "urlretrieve", side_effect=retrieve_and_inspect
+ ):
+ with self.assertRaises(OSError):
+ PyjniusLoader.download_rosim_jar()
+
+ self.assertEqual(len(targets), 1)
+ # Assert on the name itself: globbing the bin folder would also come up empty if the
+ # download were written somewhere else entirely, passing for the wrong reason.
+ self.assertEqual(os.path.dirname(targets[0]), self.bin_dir)
+ self.assertFalse(
+ fnmatch.fnmatch(os.path.basename(targets[0]), "rosim*.jar"),
+ f"in-flight download {os.path.basename(targets[0])} matches the cache glob",
+ )
+
+
+class LfsPointerDigestTest(unittest.TestCase):
+ """Testcase for the git-LFS pointer parsing used by the pinned-constant guard.
+
+ The pinned-digest test below reads a real JAR, so these branches are never exercised
+ there. Without direct coverage a broken parser would silently make that guard hash the
+ wrong bytes rather than fail.
+ """
+
+ def setUp(self) -> None:
+ """Provide a temporary folder to write pointer fixtures into."""
+ self._temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self._temp_dir.cleanup)
+
+ def _write(self, text: str, name: str = "stub") -> str:
+ """Write a fixture file and return its path."""
+ path = os.path.join(self._temp_dir.name, name)
+ with open(path, "w", encoding="utf-8") as file_handle:
+ file_handle.write(text)
+ return path
+
+ def test_reads_oid_from_pointer(self):
+ """A well-formed pointer yields the digest recorded in its oid line."""
+ oid = "a" * 64
+ path = self._write(
+ "version https://git-lfs.github.com/spec/v1\n" f"oid sha256:{oid}\nsize 129225676\n"
+ )
+ self.assertEqual(_lfs_pointer_digest(path), oid)
+
+ def test_returns_none_for_large_file(self):
+ """Content above the pointer size limit is rejected on size alone.
+
+ The fixture is a valid pointer padded past the limit, so only the size guard can
+ reject it; a fixture that also failed the header check would pass even with the
+ size guard removed.
+ """
+ oid = "c" * 64
+ padding = "\n" * _LFS_POINTER_MAX_SIZE
+ path = self._write(
+ "version https://git-lfs.github.com/spec/v1\n" f"oid sha256:{oid}\n{padding}"
+ )
+ self.assertGreater(os.path.getsize(path), _LFS_POINTER_MAX_SIZE)
+ self.assertIsNone(_lfs_pointer_digest(path))
+
+ def test_returns_none_for_binary_content(self):
+ """A small binary file is not a pointer and must not raise while being checked."""
+ path = os.path.join(self._temp_dir.name, "binary")
+ with open(path, "wb") as file_handle:
+ file_handle.write(b"\xff\xfe\x00\x01")
+ self.assertIsNone(_lfs_pointer_digest(path))
+
+ def test_returns_none_without_pointer_header(self):
+ """Small text lacking the LFS version header is not a pointer."""
+ path = self._write(f"oid sha256:{'b' * 64}\n")
+ self.assertIsNone(_lfs_pointer_digest(path))
+
+ def test_returns_none_when_oid_line_missing(self):
+ """A pointer header with no oid line yields no digest rather than a wrong one."""
+ path = self._write("version https://git-lfs.github.com/spec/v1\nsize 42\n")
+ self.assertIsNone(_lfs_pointer_digest(path))
+
+
+class PinnedConstantsMatchRepositoryTest(unittest.TestCase):
+ """Guard the pinned constants against drifting from the JAR committed in `bin`.
+
+ `upload_rosim_to_release.yml` publishes whatever `rosim*.jar` sits in `bin`, while the
+ loader reads a hardcoded release asset. Nothing links the two, so swapping the JAR without
+ updating the constants leaves the pin serving the previous JAR whose checksum still
+ matches -- a silently wrong Rosim version rather than a visible failure. Replacing the JAR
+ without updating `ROSIM_JAR_NAME` and `ROSIM_JAR_SHA256` fails here instead.
+
+ Two limits are deliberate. `ROSIM_RELEASE_TAG` is checked against nothing, so bumping the
+ tag alone is not caught; it points the URL at a possibly absent asset and surfaces as a
+ download failure at runtime, which is loud enough. And an empty `bin` skips rather than
+ fails, because `pyproject.toml` does not ship `rosim-batch-*.jar` as package data: an
+ installed package has no JAR until the first download, and only a repository checkout
+ carries the committed one these tests compare against.
+ """
+
+ def setUp(self) -> None:
+ """Locate the JAR committed in the real `bin` folder."""
+ self.bin_dir = pyjnius_loader._get_bin_path()
+ self.jars = sorted(
+ os.path.basename(path) for path in glob.glob(os.path.join(self.bin_dir, "rosim*.jar"))
+ )
+
+ def test_at_most_one_rosim_jar_is_committed(self):
+ """Several candidate JARs make the loader's cache short-circuit ambiguous.
+
+ Zero JARs is not a drift signal: `pyproject.toml` does not ship `rosim-batch-*.jar`
+ as package data, so an installed package legitimately has none until the first
+ download. Only the ambiguous case is a failure.
+ """
+ self.assertLessEqual(
+ len(self.jars), 1, f"expected at most one rosim JAR in bin, found {self.jars}"
+ )
+
+ def _single_jar(self) -> str:
+ """Return the committed rosim JAR, skipping when there is no single one to compare.
+
+ An empty `bin` is legitimate outside a repository checkout; an ambiguous one is
+ reported by the count test above rather than again here.
+ """
+ if len(self.jars) != 1:
+ self.skipTest(f"no single rosim JAR in bin to compare against, found {self.jars}")
+ return self.jars[0]
+
+ def test_pinned_name_matches_committed_jar(self):
+ """ROSIM_JAR_NAME must name the JAR actually shipped in `bin`."""
+ self.assertEqual(
+ pyjnius_loader.ROSIM_JAR_NAME,
+ self._single_jar(),
+ "ROSIM_JAR_NAME does not match the JAR in bin; bump the pinned constants "
+ "(tag, name and SHA-256) together with the JAR.",
+ )
+
+ def test_pinned_digest_matches_committed_jar(self):
+ """ROSIM_JAR_SHA256 must match the committed JAR's content digest.
+
+ On a checkout without git-LFS content the file is a small pointer stub, whose
+ `oid sha256:` line records the real digest; hashing the stub would compare the wrong
+ bytes. The stub is read in that case so the check stays meaningful either way.
+ """
+ jar_path = os.path.join(self.bin_dir, self._single_jar())
+ digest = _lfs_pointer_digest(jar_path)
+ if digest is None:
+ digest = pyjnius_loader._sha256_of_file(jar_path)
+
+ self.assertEqual(
+ digest,
+ pyjnius_loader.ROSIM_JAR_SHA256,
+ "ROSIM_JAR_SHA256 does not match the JAR in bin; bump the pinned constants "
+ "(tag, name and SHA-256) together with the JAR.",
+ )