diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6232630..1f02bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,29 @@ on: branches: [main] jobs: + test-rar: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Install RAR CLI + run: | + sudo apt-get update + sudo apt-get install --yes rar unrar + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Test archive support with RAR installed + run: uv run pytest tests/test_archives.py -q + test: runs-on: ubuntu-latest strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 831a8ae..c8d16b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Testing + +- **RAR archive coverage in CI** — a dedicated Ubuntu job installs `rar` and `unrar` and runs + the archive tests with RAR enabled. On macOS, tests assess `rar` with Gatekeeper before using it + and skip RAR coverage if macOS rejects it, avoiding blocked-executable popups. + ### Added - **Manual reload and auto-reload controls in `serve`** — the web UI gained a toolbar **Reload** diff --git a/tests/conftest.py b/tests/conftest.py index 008ae0f..becbb6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,10 @@ from __future__ import annotations import io +import os import shutil import subprocess +import sys import tarfile import tempfile import zipfile @@ -24,6 +26,58 @@ ] +def _usable_rar_cli() -> str | None: + """Return RAR CLI only when available and permitted by platform policy. + + Starting a Gatekeeper-blocked binary can open a system dialog even when + launched with a timeout. On macOS, use ``spctl`` to assess the executable + without launching it. Set DIRPLOT_TEST_RAR=1 to override a rejected or + unavailable assessment. Elsewhere, a bounded archive creation probe checks + that the executable works. + """ + if sys.platform == "darwin": + rar_cli = shutil.which("rar") + if rar_cli is None: + return None + if os.environ.get("DIRPLOT_TEST_RAR") == "1": + return rar_cli + spctl = shutil.which("spctl") or "/usr/sbin/spctl" + try: + assessment = subprocess.run( + [spctl, "--assess", "--type", "execute", rar_cli], + capture_output=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return rar_cli if assessment.returncode == 0 else None + + rar_cli = shutil.which("rar") + if rar_cli is None: + return None + try: + with tempfile.TemporaryDirectory() as tmp: + probe_dir = Path(tmp) + source = probe_dir / "probe.txt" + source.write_text("probe", encoding="utf-8") + archive = probe_dir / "probe.rar" + subprocess.run( + [rar_cli, "a", str(archive), str(source)], + capture_output=True, + timeout=3, + check=True, + ) + if not archive.is_file() or archive.stat().st_size == 0: + return None + except (OSError, subprocess.SubprocessError): + return None + return rar_cli + + +RAR_CLI = _usable_rar_cli() + + def _zip_bytes() -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: @@ -103,8 +157,7 @@ def sample_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path] archives[".7z"] = p # RAR (only if the rar CLI is available) - rar_cli = shutil.which("rar") - if rar_cli: + if RAR_CLI: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) for name, data in _SAMPLE_FILES: @@ -112,10 +165,18 @@ def sample_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path] f.parent.mkdir(parents=True, exist_ok=True) f.write_bytes(data) p = base / "sample.rar" - subprocess.run( - [rar_cli, "a", "-r", str(p), "."], cwd=tmp_path, check=True, capture_output=True - ) - archives[".rar"] = p + try: + subprocess.run( + [RAR_CLI, "a", "-r", str(p), "."], + cwd=tmp_path, + check=True, + capture_output=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + pass + else: + archives[".rar"] = p # .tar.zst / .tzst (only if bsdtar with zstd support is available) bsdtar_cli = shutil.which("bsdtar") @@ -212,8 +273,7 @@ def encrypted_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Pa archives[".zip"] = p # --- RAR with header encryption (requires rar CLI) --- - rar_cli = shutil.which("rar") - if rar_cli: + if RAR_CLI: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) for name, data in _SAMPLE_FILES: @@ -221,13 +281,18 @@ def encrypted_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Pa f.parent.mkdir(parents=True, exist_ok=True) f.write_bytes(data) p = base / "encrypted.rar" - subprocess.run( - [rar_cli, "a", "-r", f"-hp{ENCRYPTED_PASSWORD}", str(p), "."], - cwd=str(tmp_path), - check=True, - capture_output=True, - ) - archives[".rar"] = p + try: + subprocess.run( + [RAR_CLI, "a", "-r", f"-hp{ENCRYPTED_PASSWORD}", str(p), "."], + cwd=str(tmp_path), + check=True, + capture_output=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + pass + else: + archives[".rar"] = p return archives diff --git a/tests/test_archives.py b/tests/test_archives.py index 26294a9..4750ca6 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -14,7 +14,7 @@ from dirplot.archives import PasswordRequired, build_tree_archive, is_archive_path from dirplot.main import app -from tests.conftest import ENCRYPTED_PASSWORD +from tests.conftest import ENCRYPTED_PASSWORD, RAR_CLI, _usable_rar_cli # --------------------------------------------------------------------------- # is_archive_path @@ -266,8 +266,35 @@ def test_7z_dotfile_skipped(tmp_path: Path) -> None: # RAR tests (skipped if the `rar` CLI is not installed) # --------------------------------------------------------------------------- -rar_cli = shutil.which("rar") -skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed") +rar_cli = RAR_CLI +skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed or not runnable") + + +def test_macos_rar_gatekeeper_rejection_skips_cli(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("tests.conftest.sys.platform", "darwin") + monkeypatch.delenv("DIRPLOT_TEST_RAR", raising=False) + monkeypatch.setattr("tests.conftest.shutil.which", lambda name: f"/fake/{name}") + + def reject(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + assert args[0] == ["/fake/spctl", "--assess", "--type", "execute", "/fake/rar"] + return subprocess.CompletedProcess(args[0], 3, stdout=b"rejected", stderr=b"") + + monkeypatch.setattr("tests.conftest.subprocess.run", reject) + + assert _usable_rar_cli() is None + + +def test_macos_rar_gatekeeper_acceptance_enables_cli(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("tests.conftest.sys.platform", "darwin") + monkeypatch.delenv("DIRPLOT_TEST_RAR", raising=False) + monkeypatch.setattr("tests.conftest.shutil.which", lambda name: f"/fake/{name}") + + def accept(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(args[0], 0, stdout=b"accepted", stderr=b"") + + monkeypatch.setattr("tests.conftest.subprocess.run", accept) + + assert _usable_rar_cli() == "/fake/rar" def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: @@ -281,7 +308,11 @@ def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: dest.write_bytes(data) archive = tmp_path / "test.rar" subprocess.run( - [rar_cli, "a", "-r", str(archive), "."], cwd=src, check=True, capture_output=True + [rar_cli, "a", "-r", str(archive), "."], + cwd=src, + check=True, + capture_output=True, + timeout=15, ) return archive