diff --git a/src/polystore/imagej_distribution.py b/src/polystore/imagej_distribution.py index d9bfbe5..398134f 100644 --- a/src/polystore/imagej_distribution.py +++ b/src/polystore/imagej_distribution.py @@ -14,12 +14,12 @@ import urllib.request import zipfile from abc import ABC, abstractmethod -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass from enum import Enum from pathlib import Path, PurePosixPath -from typing import Any +from typing import Any, ClassVar from platformdirs import user_cache_path @@ -141,10 +141,37 @@ def extract_into(self, archive_path: Path, target_directory: Path) -> None: @dataclass(frozen=True, slots=True) class ImageJArchiveDownloadPolicy: - """Retry and timeout policy shared by declared ImageJ archives.""" + """Admission, retry and timeout policy shared by declared ImageJ archives.""" + + allow_download_environment_key: ClassVar[str] = "POLYSTORE_IMAGEJ_ALLOW_DOWNLOAD" timeout_seconds: float = 60.0 retry_delays_seconds: tuple[float, ...] = (1.0, 2.0, 4.0) + allow_download: bool = True + + @classmethod + def from_environment( + cls, environment: Mapping[str, str] | None = None + ) -> ImageJArchiveDownloadPolicy: + """Decode download permission once at process configuration.""" + values = os.environ if environment is None else environment + declared = values.get(cls.allow_download_environment_key) + if declared is None: + return cls() + normalized = declared.strip().casefold() + if normalized not in {"true", "false"}: + raise ImageJDistributionUnavailableError( + f"{cls.allow_download_environment_key} must be true or false." + ) + return cls(allow_download=normalized == "true") + + def require_download_allowed(self, label: str) -> None: + """Reject missing artifacts before staging, cache mutation or network IO.""" + if not self.allow_download: + raise ImageJDistributionUnavailableError( + f"Downloading {label} is forbidden by the ImageJ download policy; " + "prepare the declared bundle cache before starting this process." + ) def download( self, @@ -154,6 +181,7 @@ def download( ) -> Path: """Download a verified archive under this retry policy.""" + self.require_download_allowed(archive.label) retry_delays = iter(self.retry_delays_seconds) while True: try: @@ -354,6 +382,8 @@ def apply( class FijiArchiveDistribution(ImageJDistributionABC): """Checksummed Fiji application bundle from one immutable official archive.""" + cache_root_environment_key: ClassVar[str] = "POLYSTORE_IMAGEJ_CACHE_ROOT" + release_id: str imagej_version: str archive_base_url: str @@ -362,6 +392,27 @@ class FijiArchiveDistribution(ImageJDistributionABC): lock_timeout_seconds: float = 1800.0 download_policy: ImageJArchiveDownloadPolicy = ImageJArchiveDownloadPolicy() + @classmethod + def cache_root_from_environment( + cls, environment: Mapping[str, str] | None = None + ) -> Path | None: + """Decode the bundle-only root at process configuration, not each use. + + This does not redirect XDG caches, logs, preferences or other runtime + artifacts. Unset retains platformdirs policy; invalid explicit settings + fail instead of silently creating another per-run bundle. + """ + values = os.environ if environment is None else environment + declared = values.get(cls.cache_root_environment_key) + if declared is None: + return None + root = Path(declared).expanduser() + if not declared.strip() or not root.is_absolute(): + raise ImageJDistributionUnavailableError( + f"{cls.cache_root_environment_key} must be an absolute bundle-cache path." + ) + return root.resolve(strict=False) + @property def label(self) -> str: """Return the declared Fiji version and immutable archive revision.""" @@ -377,7 +428,6 @@ def materialize(self) -> ImageJRuntimeLaunch: if self.cache_root is not None else user_cache_path("polystore") / "imagej" ) - cache_parent.mkdir(parents=True, exist_ok=True) runtime_digest = hashlib.sha256( "".join((asset.sha256, *(overlay.sha256 for overlay in self.runtime_overlays))).encode( "ascii" @@ -390,6 +440,8 @@ def materialize(self) -> ImageJRuntimeLaunch: if materialization is not None: return materialization + self.download_policy.require_download_allowed(self.label) + cache_parent.mkdir(parents=True, exist_ok=True) lock_path = cache_parent / f".{runtime_directory.name}.lock" with file_lock(lock_path, timeout=self.lock_timeout_seconds): materialization = self._discover_runtime(runtime_directory) @@ -494,6 +546,8 @@ def require_compatible_gateway(self, gateway: Any) -> None: release_id="20260718-0417", imagej_version="2.18.0", archive_base_url=("https://downloads.imagej.net/fiji/archive/latest/20260718-0417"), + cache_root=FijiArchiveDistribution.cache_root_from_environment(), + download_policy=ImageJArchiveDownloadPolicy.from_environment(), runtime_overlays=( ImageJRuntimeOverlay( label="PyImageJ 1.7.0 Fiji bridge overlay", diff --git a/tests/imagej_cache_process_fixture.py b/tests/imagej_cache_process_fixture.py new file mode 100644 index 0000000..d7dce1d --- /dev/null +++ b/tests/imagej_cache_process_fixture.py @@ -0,0 +1,72 @@ +"""Tiny verified artifact response for fresh-process bundle-cache tests; no JVM.""" + +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import dataclass, field, replace +from pathlib import Path +from unittest.mock import patch + +import polystore.imagej_distribution as distribution_module +from polystore.imagej_distribution import ( + FIJI_IMAGEJ_DISTRIBUTION, + FijiBundleAsset, + ImageJArchiveDownloadPolicy, + ImageJRuntimeArchive, +) +from polystore.imagej_runtime import FIJI_IMAGEJ_RUNTIME + + +@dataclass(frozen=True) +class FixtureAsset: + source: Path + sha256: str + name: str = "LOCAL_FIXTURE" + + def archive(self, *, archive_base_url, label): + return ImageJRuntimeArchive(label, self.source.as_uri(), self.sha256) + + +@dataclass(frozen=True) +class CountedDownloadPolicy(ImageJArchiveDownloadPolicy): + downloaded: list[str] = field(default_factory=list) + + def download(self, archive, *, target_directory): + self.downloaded.append(archive.sha256) + return super().download(archive, target_directory=target_directory) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archive", type=Path) + parser.add_argument("sha256") + args = parser.parse_args() + assert FIJI_IMAGEJ_RUNTIME.distribution is FIJI_IMAGEJ_DISTRIBUTION + policy = CountedDownloadPolicy(retry_delays_seconds=()) + # Only the external artifact/host response is controlled. Canonical root, + # checksum verification, cache locking/staging/discovery remain production. + distribution = replace( + FIJI_IMAGEJ_RUNTIME.distribution, runtime_overlays=(), download_policy=policy + ) + with patch.object( + FijiBundleAsset, "for_current_host", return_value=FixtureAsset(args.archive, args.sha256) + ): + launch = distribution.materialize() + print( + json.dumps( + { + "cache_root": str(FIJI_IMAGEJ_DISTRIBUTION.cache_root), + "imagej_directory": str(launch.imagej_directory), + "java_home": str(launch.java_home), + "downloads": len(policy.downloaded), + "xdg_cache_home": os.environ["XDG_CACHE_HOME"], + "source": str(Path(distribution_module.__file__).resolve()), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_imagej_cache_environment.py b/tests/test_imagej_cache_environment.py new file mode 100644 index 0000000..4d6b272 --- /dev/null +++ b/tests/test_imagej_cache_environment.py @@ -0,0 +1,128 @@ +"""Bundle-only configuration survives independent process cache policies.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from polystore.imagej_distribution import ( + FijiArchiveDistribution, + ImageJArchiveDownloadPolicy, + ImageJDistributionUnavailableError, + ImageJRuntimeArchive, +) + + +@pytest.mark.parametrize("value, allowed", ((None, True), ("true", True), (" FALSE ", False))) +def test_download_policy_decodes_permission_without_copying_retry_defaults(value, allowed): + environment = {} if value is None else { + ImageJArchiveDownloadPolicy.allow_download_environment_key: value + } + policy = ImageJArchiveDownloadPolicy.from_environment(environment) + assert policy.allow_download is allowed + default = ImageJArchiveDownloadPolicy() + assert policy.timeout_seconds == default.timeout_seconds + assert policy.retry_delays_seconds == default.retry_delays_seconds + + +@pytest.mark.parametrize("value", ("", "yes", "0", "relative/cache")) +def test_invalid_download_permission_fails(value): + with pytest.raises(ImageJDistributionUnavailableError, match="true or false"): + ImageJArchiveDownloadPolicy.from_environment({ + ImageJArchiveDownloadPolicy.allow_download_environment_key: value + }) + + +def test_forbidden_download_has_no_archive_or_cache_side_effects(monkeypatch, tmp_path): + def forbidden_io(*args, **kwargs): + pytest.fail("Forbidden policy reached archive IO") + + monkeypatch.setattr(ImageJRuntimeArchive, "download_verified_once", forbidden_io) + root = tmp_path / "absent-cache" + policy = ImageJArchiveDownloadPolicy(allow_download=False) + with pytest.raises(ImageJDistributionUnavailableError, match="forbidden"): + policy.download( + ImageJRuntimeArchive("fixture", "https://invalid.example/fixture.zip", "0" * 64), + target_directory=root, + ) + distribution = FijiArchiveDistribution( + release_id="fixture", imagej_version="fixture", archive_base_url="https://invalid.example", + cache_root=root, download_policy=policy, + ) + with pytest.raises(ImageJDistributionUnavailableError, match="forbidden"): + distribution.materialize() + assert not root.exists() + + +def test_unset_bundle_root_retains_distribution_default(): + assert FijiArchiveDistribution.cache_root_from_environment({}) is None + + +@pytest.mark.parametrize("value", ("", " ", "relative/cache")) +def test_invalid_explicit_bundle_root_fails_without_a_fallback(value): + with pytest.raises(ImageJDistributionUnavailableError, match="absolute bundle-cache"): + FijiArchiveDistribution.cache_root_from_environment( + {FijiArchiveDistribution.cache_root_environment_key: value} + ) + + +def test_bundle_root_decode_is_independent_of_xdg_cache(tmp_path): + root = tmp_path / "bundles" + for run in ("first", "second"): + assert ( + FijiArchiveDistribution.cache_root_from_environment( + { + FijiArchiveDistribution.cache_root_environment_key: str(root), + "XDG_CACHE_HOME": str(tmp_path / run), + } + ) + == root + ) + + +def test_fresh_processes_share_one_verified_bundle_not_their_xdg_caches(tmp_path): + archive = tmp_path / "tiny-fiji.zip" + with zipfile.ZipFile(archive, "w") as fixture: + fixture.writestr("Fiji/jars/core.jar", b"verified fixture") + fixture.writestr("Fiji/plugins/plugin.jar", b"fixture") + fixture.writestr("Fiji/java/linux64/jdk/bin/java", b"no JVM execution") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + root = tmp_path / "shared-bundles" + helper = Path(__file__).with_name("imagej_cache_process_fixture.py") + results = [] + for run in ("first", "second"): + isolated = tmp_path / run + isolated.mkdir() + (isolated / "run.log").write_text(run) + environment = dict(os.environ) + environment[FijiArchiveDistribution.cache_root_environment_key] = str(root) + environment["XDG_CACHE_HOME"] = str(isolated) + child = subprocess.run( + [sys.executable, str(helper), str(archive), digest], + env=environment, + capture_output=True, + text=True, + check=True, + timeout=10, + ) + result = json.loads(child.stdout) + assert result["cache_root"] == str(root) + assert result["xdg_cache_home"] == str(isolated) + assert result["source"] == str( + Path(__file__).resolve().parents[1] / "src/polystore/imagej_distribution.py" + ) + assert (isolated / "run.log").read_text() == run + assert not (isolated / "polystore/imagej").exists() + results.append(result) + assert [result["downloads"] for result in results] == [1, 0] + assert results[0]["imagej_directory"] == results[1]["imagej_directory"] + assert results[0]["java_home"] == results[1]["java_home"] + assert len(tuple(root.glob("fiji-*"))) == 1 + assert not tuple(root.glob(".imagej-download.*"))