a00bccac78
info now shows the cached build that will launch AND the server's latest Pro version on separate lines, so the two can no longer silently diverge (a customer saw info report latest while launch ran a stale cache). - Pro version resolution: unpinned launch prefers the server latest when it is newer than or replaces a missing cached build, else stays on cache; advances the version marker so info and later offline launches match. - update command is license-aware: a valid Pro key updates the Pro binary, everyone else updates free. - Replace the fire-and-forget Pro background update thread with a foreground rate-limited check (one network call/hour), honoring CLOAKBROWSER_AUTO_UPDATE=false. - A valid Pro license never falls back to the free binary: get_effective_version returns None when no Pro build is cached; resolution fails loudly instead. - Tampering signal (BinaryVerificationError) surfaces verbatim on the unpinned upgrade path — the cached-Pro fallback is only for transient download failures. - get_effective_version(pro) requires the binary be executable, so info can't report a build launch would reject. - info --quick stays network-free (skips the server latest lookup); prints a 'not downloaded yet' line instead of 'None' when offline with no cache. - Align the JS .last_update_check marker to seconds (Python/.NET parity). - Mirror across Python, JS, and .NET wrappers; add update/CLI tests.
1421 lines
57 KiB
Python
1421 lines
57 KiB
Python
"""Tests for auto-update and version management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
from cloakbrowser.config import (
|
|
_version_newer,
|
|
_version_tuple,
|
|
get_chromium_version,
|
|
get_download_url,
|
|
get_effective_version,
|
|
get_platform_tag,
|
|
)
|
|
from cloakbrowser.download import (
|
|
BinaryVerificationError,
|
|
_check_wrapper_update,
|
|
_download_and_extract,
|
|
_download_pro_binary,
|
|
_ensure_pro_binary,
|
|
_fetch_checksums,
|
|
_fetch_signed_manifest,
|
|
_get_latest_chromium_version,
|
|
_parse_checksums,
|
|
_parse_manifest_version,
|
|
_should_check_for_update,
|
|
_verify_checksum,
|
|
_verify_download_checksum,
|
|
_verify_pro_download,
|
|
_verify_signature,
|
|
_write_version_marker,
|
|
check_for_pro_update,
|
|
check_for_update,
|
|
clear_cache,
|
|
ensure_binary,
|
|
)
|
|
|
|
|
|
class TestVersionComparison:
|
|
def test_version_tuple_parsing(self):
|
|
assert _version_tuple("145.0.7718.0") == (145, 0, 7718, 0)
|
|
assert _version_tuple("142.0.7444.175") == (142, 0, 7444, 175)
|
|
|
|
def test_newer_version(self):
|
|
assert _version_newer("145.0.7718.0", "142.0.7444.175") is True
|
|
|
|
def test_older_version(self):
|
|
assert _version_newer("142.0.7444.175", "145.0.7718.0") is False
|
|
|
|
def test_same_version(self):
|
|
assert _version_newer("142.0.7444.175", "142.0.7444.175") is False
|
|
|
|
def test_patch_bump(self):
|
|
assert _version_newer("142.0.7444.176", "142.0.7444.175") is True
|
|
|
|
def test_major_bump(self):
|
|
assert _version_newer("143.0.0.0", "142.9.9999.999") is True
|
|
|
|
def test_5th_segment_parsing(self):
|
|
assert _version_tuple("145.0.7632.109.2") == (145, 0, 7632, 109, 2)
|
|
|
|
def test_build_bump(self):
|
|
assert _version_newer("145.0.7632.109.3", "145.0.7632.109.2") is True
|
|
|
|
def test_build_suffix_newer_than_no_suffix(self):
|
|
assert _version_newer("145.0.7632.109.2", "145.0.7632.109") is True
|
|
|
|
def test_no_suffix_older_than_build_suffix(self):
|
|
assert _version_newer("145.0.7632.109", "145.0.7632.109.2") is False
|
|
|
|
def test_new_chromium_beats_old_build(self):
|
|
assert _version_newer("146.0.0.0", "145.0.7632.109.2") is True
|
|
|
|
|
|
class TestDownloadUrl:
|
|
def test_default_url_format(self):
|
|
url = get_download_url()
|
|
assert "cloakbrowser.dev" in url
|
|
assert f"chromium-v{get_chromium_version()}" in url
|
|
assert url.endswith(".tar.gz")
|
|
|
|
def test_custom_version_url(self):
|
|
url = get_download_url("145.0.7718.0")
|
|
assert "chromium-v145.0.7718.0" in url
|
|
|
|
def test_no_old_repo_reference(self):
|
|
url = get_download_url()
|
|
assert "chromium-stealth-builds" not in url
|
|
|
|
|
|
class TestShouldCheckForUpdate:
|
|
def test_disabled_by_env(self):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
|
|
assert _should_check_for_update() is False
|
|
|
|
def test_disabled_by_env_case_insensitive(self):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "False"}):
|
|
assert _should_check_for_update() is False
|
|
|
|
def test_disabled_by_binary_override(self):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/some/path"}):
|
|
assert _should_check_for_update() is False
|
|
|
|
def test_disabled_by_custom_download_url(self):
|
|
with patch.dict(
|
|
os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com"}
|
|
):
|
|
assert _should_check_for_update() is False
|
|
|
|
def test_rate_limited(self, tmp_path):
|
|
import time
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
"CLOAKBROWSER_AUTO_UPDATE": "",
|
|
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
|
},
|
|
):
|
|
check_file = tmp_path / ".last_update_check"
|
|
check_file.write_text(str(time.time()))
|
|
assert _should_check_for_update() is False
|
|
|
|
def test_stale_rate_limit_allows_check(self, tmp_path):
|
|
import time
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
"CLOAKBROWSER_AUTO_UPDATE": "",
|
|
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
|
},
|
|
):
|
|
check_file = tmp_path / ".last_update_check"
|
|
check_file.write_text(str(time.time() - 7200)) # 2 hours ago
|
|
assert _should_check_for_update() is True
|
|
|
|
|
|
class TestEffectiveVersion:
|
|
def test_no_marker_returns_platform_version(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
assert get_effective_version() == get_chromium_version()
|
|
|
|
def test_marker_with_newer_version(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
|
marker.write_text("999.0.0.0")
|
|
# Binary doesn't exist, so should fall back
|
|
assert get_effective_version() == get_chromium_version()
|
|
|
|
def test_marker_with_older_version_ignored(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
|
marker.write_text("100.0.0.0")
|
|
assert get_effective_version() == get_chromium_version()
|
|
|
|
|
|
class TestGetLatestVersion:
|
|
"""Tests for _get_latest_chromium_version with platform-aware asset checking."""
|
|
|
|
def _make_assets(self, platforms: list[str]) -> list[dict]:
|
|
"""Helper to build asset list from platform tags."""
|
|
return [{"name": f"cloakbrowser-{p}.tar.gz"} for p in platforms]
|
|
|
|
def _platform_tarball(self) -> str:
|
|
return f"cloakbrowser-{get_platform_tag()}.tar.gz"
|
|
|
|
def test_parses_chromium_tag_with_platform_asset(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "chromium-v145.0.7718.0",
|
|
"draft": False,
|
|
"assets": self._make_assets(
|
|
["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
|
),
|
|
},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
|
result = _get_latest_chromium_version()
|
|
assert result == "145.0.7718.0"
|
|
|
|
def test_skips_release_without_platform_asset(self):
|
|
"""If latest release has no asset for our platform, fall back to older release."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "chromium-v145.0.7718.0",
|
|
"draft": False,
|
|
"assets": self._make_assets(["linux-x64"]), # Linux only
|
|
},
|
|
{
|
|
"tag_name": "chromium-v142.0.7444.175",
|
|
"draft": False,
|
|
"assets": self._make_assets(
|
|
["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
|
),
|
|
},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
|
result = _get_latest_chromium_version()
|
|
tag = get_platform_tag()
|
|
if tag == "linux-x64":
|
|
assert result == "145.0.7718.0"
|
|
else:
|
|
assert result == "142.0.7444.175"
|
|
|
|
def test_skips_draft_releases(self):
|
|
mock_response = MagicMock()
|
|
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "chromium-v999.0.0.0",
|
|
"draft": True,
|
|
"assets": self._make_assets(all_platforms),
|
|
},
|
|
{
|
|
"tag_name": "chromium-v145.0.7718.0",
|
|
"draft": False,
|
|
"assets": self._make_assets(all_platforms),
|
|
},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
|
result = _get_latest_chromium_version()
|
|
assert result == "145.0.7718.0"
|
|
|
|
def test_skips_non_chromium_tags(self):
|
|
mock_response = MagicMock()
|
|
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "v0.2.0",
|
|
"draft": False,
|
|
"assets": self._make_assets(all_platforms),
|
|
},
|
|
{
|
|
"tag_name": "chromium-v145.0.7718.0",
|
|
"draft": False,
|
|
"assets": self._make_assets(all_platforms),
|
|
},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
|
result = _get_latest_chromium_version()
|
|
assert result == "145.0.7718.0"
|
|
|
|
def test_returns_none_when_no_platform_assets(self):
|
|
"""If no release has our platform, return None."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "chromium-v145.0.7718.0",
|
|
"draft": False,
|
|
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
|
|
},
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
|
|
result = _get_latest_chromium_version()
|
|
assert result is None
|
|
|
|
def test_network_error_returns_none(self):
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
|
|
result = _get_latest_chromium_version()
|
|
assert result is None
|
|
|
|
|
|
class TestWrapperUpdateCheck:
|
|
"""Tests for _check_wrapper_update (PyPI version check)."""
|
|
|
|
def setup_method(self):
|
|
import cloakbrowser.download as dl
|
|
|
|
dl._wrapper_update_checked = False
|
|
|
|
def test_warns_when_newer_version_available(self, caplog):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"info": {"version": "99.0.0"}}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
|
|
import logging
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
_check_wrapper_update()
|
|
assert "Update available" in caplog.text
|
|
assert "99.0.0" in caplog.text
|
|
|
|
def test_silent_when_current(self, caplog):
|
|
import cloakbrowser.download as dl
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"info": {"version": dl._wrapper_version}}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
|
|
import logging
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
_check_wrapper_update()
|
|
assert "Update available" not in caplog.text
|
|
|
|
def test_disabled_by_auto_update_env(self):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
|
|
with patch("cloakbrowser.download.httpx.get") as mock_get:
|
|
_check_wrapper_update()
|
|
mock_get.assert_not_called()
|
|
|
|
def test_disabled_by_custom_download_url(self):
|
|
with patch.dict(
|
|
os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://mirror.example.com"}
|
|
):
|
|
with patch("cloakbrowser.download.httpx.get") as mock_get:
|
|
_check_wrapper_update()
|
|
mock_get.assert_not_called()
|
|
|
|
def test_network_error_silent(self, caplog):
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
|
|
import logging
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
_check_wrapper_update()
|
|
assert "Update available" not in caplog.text
|
|
|
|
def test_runs_only_once(self):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"info": {"version": "0.0.1"}}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch(
|
|
"cloakbrowser.download.httpx.get", return_value=mock_resp
|
|
) as mock_get:
|
|
_check_wrapper_update()
|
|
_check_wrapper_update()
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
class TestParseChecksums:
|
|
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
|
|
|
def test_standard_format(self):
|
|
text = (
|
|
f"{self.HASH_A} cloakbrowser-linux-x64.tar.gz\n"
|
|
f"{self.HASH_B} cloakbrowser-darwin-arm64.tar.gz\n"
|
|
)
|
|
result = _parse_checksums(text)
|
|
assert result["cloakbrowser-linux-x64.tar.gz"] == self.HASH_A
|
|
assert result["cloakbrowser-darwin-arm64.tar.gz"] == self.HASH_B
|
|
|
|
def test_binary_mode_asterisk(self):
|
|
text = f"{self.HASH_A} *cloakbrowser-linux-x64.tar.gz\n"
|
|
result = _parse_checksums(text)
|
|
assert "cloakbrowser-linux-x64.tar.gz" in result
|
|
|
|
def test_empty_lines_skipped(self):
|
|
text = f"\n\n{self.HASH_A} file.tar.gz\n\n"
|
|
result = _parse_checksums(text)
|
|
assert len(result) == 1
|
|
|
|
def test_uppercase_lowered(self):
|
|
text = f"{self.HASH_A.upper()} file.tar.gz\n"
|
|
result = _parse_checksums(text)
|
|
assert result["file.tar.gz"] == self.HASH_A
|
|
|
|
def test_empty_input(self):
|
|
assert _parse_checksums("") == {}
|
|
assert _parse_checksums(" \n \n") == {}
|
|
|
|
|
|
class TestVerifyChecksum:
|
|
def test_matching_checksum(self, tmp_path):
|
|
content = b"test binary content"
|
|
file = tmp_path / "test.tar.gz"
|
|
file.write_bytes(content)
|
|
expected = hashlib.sha256(content).hexdigest()
|
|
# Should not raise
|
|
_verify_checksum(file, expected)
|
|
|
|
def test_mismatched_checksum(self, tmp_path):
|
|
file = tmp_path / "test.tar.gz"
|
|
file.write_bytes(b"real content")
|
|
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
|
_verify_checksum(file, "0" * 64)
|
|
|
|
|
|
class TestClearCache:
|
|
def test_removes_dir(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
# Create some content
|
|
(tmp_path / "chromium-145").mkdir()
|
|
(tmp_path / "chromium-145" / "chrome").write_bytes(b"binary")
|
|
clear_cache()
|
|
assert not tmp_path.exists()
|
|
|
|
def test_noop_if_missing(self, tmp_path):
|
|
nonexistent = tmp_path / "nonexistent"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}):
|
|
clear_cache() # Should not raise
|
|
|
|
|
|
class TestCheckForUpdate:
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_returns_none_when_current(self, _mock_update):
|
|
with patch(
|
|
"cloakbrowser.download._get_latest_chromium_version", return_value=None
|
|
):
|
|
assert check_for_update() is None
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_returns_none_on_network_error(self, _mock_update):
|
|
with patch(
|
|
"cloakbrowser.download._get_latest_chromium_version",
|
|
side_effect=Exception("timeout"),
|
|
):
|
|
# _get_latest_chromium_version catches exceptions internally, but
|
|
# check_for_update itself can also fail — test graceful None return
|
|
with patch(
|
|
"cloakbrowser.download._get_latest_chromium_version", return_value=None
|
|
):
|
|
assert check_for_update() is None
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_returns_version_when_newer(self, _mock_update, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
with patch(
|
|
"cloakbrowser.download._get_latest_chromium_version",
|
|
return_value="999.0.0.0",
|
|
):
|
|
with patch("cloakbrowser.download._download_and_extract"):
|
|
result = check_for_update()
|
|
assert result == "999.0.0.0"
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_skips_download_if_already_cached(self, _mock_update, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
# Create the binary dir so it looks already downloaded
|
|
binary_dir = tmp_path / "chromium-999.0.0.0"
|
|
binary_dir.mkdir()
|
|
with patch(
|
|
"cloakbrowser.download._get_latest_chromium_version",
|
|
return_value="999.0.0.0",
|
|
):
|
|
with patch("cloakbrowser.download._download_and_extract") as mock_dl:
|
|
result = check_for_update()
|
|
assert result == "999.0.0.0"
|
|
mock_dl.assert_not_called()
|
|
|
|
|
|
class TestEnsureBinary:
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_local_override(self, _mock_update, tmp_path):
|
|
binary = tmp_path / "chrome"
|
|
binary.write_bytes(b"binary")
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}):
|
|
result = ensure_binary()
|
|
assert result == str(binary)
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_local_override_missing_file(self, _mock_update):
|
|
with patch.dict(
|
|
os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"}
|
|
):
|
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
|
ensure_binary()
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_cached_binary_found(self, _mock_update, tmp_path):
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
},
|
|
):
|
|
# Create a fake cached binary
|
|
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
|
fake_binary = tmp_path / "chrome"
|
|
fake_binary.write_bytes(b"binary")
|
|
fake_binary.chmod(0o755)
|
|
mock_path.return_value = fake_binary
|
|
with patch("cloakbrowser.download.check_platform_available"):
|
|
result = ensure_binary()
|
|
assert result == str(fake_binary)
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_pinned_free_version_uses_exact_download(self, _mock_update, tmp_path):
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
},
|
|
):
|
|
requested = "146.0.7680.177.5"
|
|
fake_binary = tmp_path / "chrome"
|
|
with patch("cloakbrowser.download.check_platform_available"):
|
|
with patch(
|
|
"cloakbrowser.download.get_binary_path", return_value=fake_binary
|
|
):
|
|
|
|
def fake_download(version):
|
|
assert version == requested
|
|
fake_binary.write_bytes(b"binary")
|
|
fake_binary.chmod(0o755)
|
|
|
|
with patch(
|
|
"cloakbrowser.download._download_and_extract",
|
|
side_effect=fake_download,
|
|
) as mock_dl:
|
|
result = ensure_binary(browser_version=requested)
|
|
mock_dl.assert_called_once_with(requested)
|
|
assert result == str(fake_binary)
|
|
# A pinned (rollback) download must NOT write the 'latest'
|
|
# marker — an unpinned launch must still resolve to latest.
|
|
assert not (
|
|
tmp_path / f"latest_version_{get_platform_tag()}"
|
|
).exists()
|
|
assert not (tmp_path / "latest_version").exists()
|
|
|
|
def test_pinned_pro_version_skips_latest_marker(self, tmp_path):
|
|
# A pinned Pro (rollback) download must NOT write latest_pro_version_*,
|
|
# so an unpinned Pro launch still resolves to the latest build.
|
|
requested = "148.0.7778.215.2"
|
|
fake_binary = tmp_path / "Chromium"
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
with patch(
|
|
"cloakbrowser.download.get_binary_path", return_value=fake_binary
|
|
):
|
|
|
|
def fake_pro_download(version, key):
|
|
assert version == requested
|
|
fake_binary.write_bytes(b"binary")
|
|
fake_binary.chmod(0o755)
|
|
|
|
with patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=fake_pro_download,
|
|
):
|
|
result = _ensure_pro_binary("cb_key", requested_version=requested)
|
|
assert result == str(fake_binary)
|
|
assert not marker.exists()
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_pinned_free_env_rejects_unsafe_version(self, _mock_update, tmp_path):
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
"CLOAKBROWSER_VERSION": "../../146.0.0.0",
|
|
},
|
|
):
|
|
with pytest.raises(ValueError, match="Invalid browser version pin"):
|
|
ensure_binary()
|
|
|
|
@patch("cloakbrowser.download._maybe_trigger_update_check")
|
|
def test_downloads_when_missing(self, _mock_update, tmp_path):
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_BINARY_PATH": "",
|
|
},
|
|
):
|
|
fake_binary = tmp_path / "chrome"
|
|
with patch("cloakbrowser.download.check_platform_available"):
|
|
with patch("cloakbrowser.download.get_binary_path") as mock_path:
|
|
# effective == platform_version (no marker), so fallback block skipped.
|
|
# Call 1: get_binary_path(effective) → nonexistent (triggers download)
|
|
# Call 2: get_binary_path() → fake_binary (post-download verify)
|
|
mock_path.side_effect = [
|
|
tmp_path / "nonexistent", # pre-download: not cached
|
|
fake_binary, # post-download: binary ready
|
|
]
|
|
with patch(
|
|
"cloakbrowser.download._download_and_extract"
|
|
) as mock_dl:
|
|
fake_binary.write_bytes(b"binary")
|
|
result = ensure_binary()
|
|
mock_dl.assert_called_once()
|
|
assert result == str(fake_binary)
|
|
|
|
|
|
def _make_pro_binary(version: str):
|
|
"""Create a fake cached, executable Pro binary for `version`."""
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
bp = get_binary_path(version, pro=True)
|
|
bp.parent.mkdir(parents=True, exist_ok=True)
|
|
bp.write_bytes(b"binary")
|
|
bp.chmod(0o755)
|
|
return bp
|
|
|
|
|
|
class TestUnpinnedProUpgrade:
|
|
"""Ticket 431: an unpinned Pro launch must track the server's latest stable,
|
|
never roll down to a stale cached build, and never fall back to the free binary."""
|
|
|
|
OLD = "148.0.7778.215.3"
|
|
NEW = "148.0.7778.215.5"
|
|
|
|
def test_upgrades_to_server_latest(self, tmp_path):
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.OLD)
|
|
_make_pro_binary(self.OLD) # stale build cached
|
|
|
|
def fake_download(version, key):
|
|
assert version == self.NEW
|
|
_make_pro_binary(self.NEW)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=fake_download,
|
|
) as mock_dl,
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
mock_dl.assert_called_once()
|
|
assert result == str(get_binary_path(self.NEW, pro=True))
|
|
assert marker.read_text() == self.NEW # marker advanced, not stuck
|
|
|
|
def test_cached_newer_build_advances_marker_no_download(self, tmp_path):
|
|
"""Marker names an OLD build but a NEWER build is already cached (the customer's
|
|
multi-version cache): resolve to newest, no download, and advance the marker so
|
|
`info` never diverges from what launches."""
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
_make_pro_binary(self.NEW) # newer build already on disk
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
mock_dl.assert_not_called() # already cached, no download
|
|
assert result == str(get_binary_path(self.NEW, pro=True))
|
|
assert marker.read_text() == self.NEW # marker advanced, no stale divergence
|
|
|
|
def test_steady_state_no_download(self, tmp_path):
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.NEW)
|
|
_make_pro_binary(self.NEW) # already on latest
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
mock_dl.assert_not_called()
|
|
assert result == str(get_binary_path(self.NEW, pro=True))
|
|
|
|
def test_server_down_uses_cached_pro(self, tmp_path):
|
|
"""Server unreachable → launch the cached Pro build, never fail, never free."""
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
(tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version", return_value=None
|
|
),
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
mock_dl.assert_not_called()
|
|
assert result == str(get_binary_path(self.OLD, pro=True))
|
|
|
|
def test_download_failure_falls_back_to_cached(self, tmp_path):
|
|
"""A failed upgrade download falls back to the cached Pro build, not free."""
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
(tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=RuntimeError("network down"),
|
|
),
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
assert result == str(get_binary_path(self.OLD, pro=True))
|
|
|
|
def test_verification_error_surfaces_not_cached_fallback(self, tmp_path):
|
|
"""A tampering signal (BinaryVerificationError) must propagate verbatim, even
|
|
with a cached Pro build present — never masked by the cached-fallback path."""
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
(tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=BinaryVerificationError("checksum mismatch"),
|
|
),
|
|
):
|
|
with pytest.raises(BinaryVerificationError, match="checksum mismatch"):
|
|
_ensure_pro_binary("cb_key")
|
|
|
|
def test_no_cache_no_server_raises_never_free(self, tmp_path):
|
|
"""No cached Pro build AND no server → hard error, never the free binary."""
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version", return_value=None
|
|
),
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
with pytest.raises(RuntimeError, match="latest Pro version"):
|
|
_ensure_pro_binary("cb_key")
|
|
mock_dl.assert_not_called()
|
|
|
|
def test_auto_update_false_keeps_cached_no_server_check(self, tmp_path):
|
|
"""CLOAKBROWSER_AUTO_UPDATE=false + a cached Pro build → keep it, no upgrade,
|
|
no server check (parity with the free path's freeze semantics)."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_AUTO_UPDATE": "false",
|
|
},
|
|
):
|
|
(tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
) as mock_latest,
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
mock_dl.assert_not_called()
|
|
mock_latest.assert_not_called() # frozen → no server check at all
|
|
assert result == str(get_binary_path(self.OLD, pro=True))
|
|
|
|
def test_missing_cache_downloads_latest_never_free(self, tmp_path):
|
|
"""Marker names a build whose binary is gone → fetch latest Pro, never 146.x."""
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.OLD) # marker present, but NO binary on disk
|
|
|
|
def fake_download(version, key):
|
|
assert version == self.NEW # never the free base (146.x)
|
|
_make_pro_binary(self.NEW)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=fake_download,
|
|
),
|
|
):
|
|
from cloakbrowser.config import get_binary_path
|
|
|
|
result = _ensure_pro_binary("cb_key")
|
|
assert result == str(get_binary_path(self.NEW, pro=True))
|
|
|
|
|
|
class TestCheckForProUpdate:
|
|
"""`cloakbrowser update` for Pro installs (ticket 431 Fix 1)."""
|
|
|
|
OLD = "148.0.7778.215.3"
|
|
NEW = "148.0.7778.215.5"
|
|
|
|
def test_downloads_and_writes_marker(self, tmp_path):
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.OLD)
|
|
_make_pro_binary(self.OLD)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch(
|
|
"cloakbrowser.download._download_pro_binary",
|
|
side_effect=lambda v, k: _make_pro_binary(v),
|
|
) as mock_dl,
|
|
):
|
|
result = check_for_pro_update("cb_key")
|
|
mock_dl.assert_called_once()
|
|
assert result == self.NEW
|
|
assert marker.read_text() == self.NEW
|
|
|
|
def test_already_latest_returns_none(self, tmp_path):
|
|
marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
marker.write_text(self.NEW)
|
|
_make_pro_binary(self.NEW)
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.license.get_pro_latest_version",
|
|
return_value=self.NEW,
|
|
),
|
|
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
|
|
):
|
|
assert check_for_pro_update("cb_key") is None
|
|
mock_dl.assert_not_called()
|
|
|
|
def test_server_down_returns_none(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
with patch(
|
|
"cloakbrowser.license.get_pro_latest_version", return_value=None
|
|
):
|
|
assert check_for_pro_update("cb_key") is None
|
|
|
|
|
|
class TestEffectiveVersionProNoFreeFallback:
|
|
"""get_effective_version(pro=True) must return None — never the free base —
|
|
when no cached Pro binary matches the marker (ticket 431 Fix 4)."""
|
|
|
|
def test_none_when_no_cached_pro_binary(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
# Marker points at a version whose binary is not on disk.
|
|
(tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(
|
|
"148.0.7778.215.5"
|
|
)
|
|
assert get_effective_version(pro=True) is None
|
|
|
|
def test_none_when_no_marker(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
assert get_effective_version(pro=True) is None
|
|
# Free tier still resolves to a concrete version.
|
|
assert get_effective_version(pro=False) == get_chromium_version()
|
|
|
|
|
|
class TestWriteVersionMarker:
|
|
def test_creates_file(self, tmp_path):
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
|
_write_version_marker("999.0.0.0")
|
|
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
|
assert marker.exists()
|
|
assert marker.read_text() == "999.0.0.0"
|
|
|
|
|
|
class TestDownloadFallback:
|
|
"""Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors."""
|
|
|
|
def test_binary_download_falls_back_on_http_error(self, tmp_path):
|
|
"""HTTP error from primary triggers GitHub Releases fallback for binary download."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
|
},
|
|
):
|
|
urls_called = []
|
|
|
|
def mock_download_file(url, dest):
|
|
urls_called.append(url)
|
|
if "cloakbrowser.dev" in url:
|
|
raise Exception("HTTP 429 Too Many Requests")
|
|
# GitHub fallback succeeds
|
|
dest.write_bytes(b"fake")
|
|
|
|
# This test exercises URL fallback, not verification — stub the
|
|
# (now signature-based, non-bypassable) verify step.
|
|
with (
|
|
patch(
|
|
"cloakbrowser.download._download_file",
|
|
side_effect=mock_download_file,
|
|
),
|
|
patch("cloakbrowser.download._verify_download_checksum"),
|
|
patch("cloakbrowser.download._extract_archive"),
|
|
patch("cloakbrowser.download._show_welcome"),
|
|
):
|
|
_download_and_extract()
|
|
|
|
assert len(urls_called) == 2
|
|
assert "cloakbrowser.dev" in urls_called[0]
|
|
assert "github.com" in urls_called[1]
|
|
|
|
def test_binary_download_no_fallback_with_custom_url(self, tmp_path):
|
|
"""Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
|
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases",
|
|
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
|
},
|
|
):
|
|
with patch(
|
|
"cloakbrowser.download._download_file", side_effect=Exception("503")
|
|
):
|
|
with pytest.raises(Exception, match="503"):
|
|
_download_and_extract()
|
|
|
|
def test_checksum_fetch_falls_back_on_http_error(self):
|
|
"""HTTP error from primary checksum URL triggers GitHub fallback."""
|
|
valid_checksums = (
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
" cloakbrowser-linux-x64.tar.gz\n"
|
|
)
|
|
|
|
def mock_get(url, **kwargs):
|
|
resp = MagicMock()
|
|
if "cloakbrowser.dev" in url:
|
|
resp.raise_for_status.side_effect = Exception("HTTP 429")
|
|
return resp
|
|
# GitHub URL succeeds
|
|
resp.text = valid_checksums
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
|
result = _fetch_checksums()
|
|
|
|
assert result is not None
|
|
assert "cloakbrowser-linux-x64.tar.gz" in result
|
|
|
|
def test_checksum_fetch_returns_none_when_both_fail(self):
|
|
"""Both primary and GitHub checksum URLs fail → returns None (skip verification)."""
|
|
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
|
|
with patch(
|
|
"cloakbrowser.download.httpx.get",
|
|
side_effect=Exception("network error"),
|
|
):
|
|
result = _fetch_checksums()
|
|
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signed-manifest verification (Ed25519). Trust root is the pinned public key,
|
|
# not the same-origin SHA256SUMS — this is what closes M1 (#308).
|
|
# ---------------------------------------------------------------------------
|
|
def _make_key():
|
|
priv = Ed25519PrivateKey.generate()
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
raw = priv.public_key().public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw,
|
|
)
|
|
return priv, base64.b64encode(raw).decode()
|
|
|
|
|
|
def _sign(priv, manifest_bytes: bytes) -> bytes:
|
|
"""Return SHA256SUMS.sig content (base64 of the raw signature), as served."""
|
|
return base64.b64encode(priv.sign(manifest_bytes))
|
|
|
|
|
|
class TestSignatureVerification:
|
|
"""_verify_signature: the cryptographic gate over the raw manifest bytes."""
|
|
|
|
def test_valid_signature_passes(self):
|
|
priv, pub_b64 = _make_key()
|
|
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
|
|
sig = _sign(priv, manifest)
|
|
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
|
_verify_signature(manifest, sig) # no raise
|
|
|
|
def test_tampered_manifest_fails(self):
|
|
priv, pub_b64 = _make_key()
|
|
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
|
|
sig = _sign(priv, manifest)
|
|
tampered = manifest.replace(b"abc", b"xyz")
|
|
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
|
with pytest.raises(RuntimeError, match="signature verification failed"):
|
|
_verify_signature(tampered, sig)
|
|
|
|
def test_wrong_key_fails(self):
|
|
priv, _ = _make_key()
|
|
_, other_pub = _make_key()
|
|
manifest = b"data\n"
|
|
sig = _sign(priv, manifest)
|
|
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [other_pub]):
|
|
with pytest.raises(RuntimeError, match="signature verification failed"):
|
|
_verify_signature(manifest, sig)
|
|
|
|
def test_malformed_signature_fails(self):
|
|
_, pub_b64 = _make_key()
|
|
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
|
with pytest.raises(RuntimeError, match="Malformed"):
|
|
_verify_signature(b"data\n", b"!!!not base64!!!")
|
|
|
|
def test_placeholder_key_is_skipped_not_crashing(self):
|
|
"""An unparseable pinned key (placeholder) must not abort — a real key still validates."""
|
|
priv, pub_b64 = _make_key()
|
|
manifest = b"data\n"
|
|
sig = _sign(priv, manifest)
|
|
with patch(
|
|
"cloakbrowser.download.BINARY_SIGNING_PUBKEYS",
|
|
["REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub_b64],
|
|
):
|
|
_verify_signature(manifest, sig) # no raise
|
|
|
|
def test_key_rotation_second_key_accepts(self):
|
|
"""A manifest signed with the new key validates while the old key stays pinned."""
|
|
old_priv, old_pub = _make_key()
|
|
new_priv, new_pub = _make_key()
|
|
manifest = b"rotated\n"
|
|
sig = _sign(new_priv, manifest)
|
|
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [old_pub, new_pub]):
|
|
_verify_signature(manifest, sig) # no raise
|
|
|
|
|
|
class TestVerifyDownloadChecksumSigned:
|
|
"""_verify_download_checksum on the official path: signature + version + hash, fail-closed."""
|
|
|
|
def _hash(self, data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
def _manifest(self, body: str, version: str | None = None) -> bytes:
|
|
"""Build a signed-manifest body with the bound version line prepended."""
|
|
v = version if version is not None else get_chromium_version()
|
|
return f"version={v}\n{body}".encode()
|
|
|
|
def test_valid_manifest_and_hash_passes(self, tmp_path):
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real binary")
|
|
tarball = get_download_url().rsplit("/", 1)[-1]
|
|
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
|
|
sig = _sign(priv, manifest)
|
|
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download._fetch_signed_manifest",
|
|
return_value=(manifest, sig),
|
|
),
|
|
):
|
|
_verify_download_checksum(archive) # no raise
|
|
|
|
def test_tampered_binary_fails_hash(self, tmp_path):
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"a malicious binary") # different bytes
|
|
tarball = get_download_url().rsplit("/", 1)[-1]
|
|
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
|
|
sig = _sign(priv, manifest)
|
|
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download._fetch_signed_manifest",
|
|
return_value=(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
|
_verify_download_checksum(archive)
|
|
|
|
def test_wrong_version_fails_downgrade(self, tmp_path):
|
|
"""A genuinely-signed manifest for a DIFFERENT version is rejected (downgrade)."""
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real binary")
|
|
tarball = get_download_url().rsplit("/", 1)[-1]
|
|
# Manifest declares an old version, but we ask for get_chromium_version().
|
|
manifest = self._manifest(
|
|
f"{self._hash(b'the real binary')} {tarball}\n", version="1.0.0.0"
|
|
)
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download._fetch_signed_manifest",
|
|
return_value=(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="Version mismatch"):
|
|
_verify_download_checksum(archive)
|
|
|
|
def test_missing_version_line_fails(self, tmp_path):
|
|
"""A signed manifest without a version line is rejected (binding required)."""
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real binary")
|
|
tarball = get_download_url().rsplit("/", 1)[-1]
|
|
manifest = (
|
|
f"{self._hash(b'the real binary')} {tarball}\n".encode()
|
|
) # no version=
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download._fetch_signed_manifest",
|
|
return_value=(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="Version mismatch"):
|
|
_verify_download_checksum(archive)
|
|
|
|
def test_missing_signed_manifest_fails_closed(self, tmp_path):
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"x")
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download._fetch_signed_manifest", return_value=None),
|
|
):
|
|
with pytest.raises(RuntimeError, match="signed SHA256SUMS"):
|
|
_verify_download_checksum(archive)
|
|
|
|
def test_manifest_without_entry_fails(self, tmp_path):
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"x")
|
|
manifest = self._manifest(
|
|
"deadbeef some-other-file.tar.gz\n"
|
|
) # no entry for our tarball
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download._fetch_signed_manifest",
|
|
return_value=(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="no entry for"):
|
|
_verify_download_checksum(archive)
|
|
|
|
def test_custom_url_uses_plain_checksum_and_skip(self, tmp_path):
|
|
"""Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path."""
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"x")
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.test",
|
|
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
|
},
|
|
):
|
|
# Signature path must NOT be consulted for a custom mirror.
|
|
with patch("cloakbrowser.download._fetch_signed_manifest") as mocked:
|
|
_verify_download_checksum(archive) # skip honored, no raise
|
|
mocked.assert_not_called()
|
|
|
|
|
|
class TestVerifyProDownloadSigned:
|
|
"""_verify_pro_download: Pro binaries get the SAME non-bypassable signature
|
|
check as the free official path (parity — closes the Pro M1 gap)."""
|
|
|
|
PRO_VERSION = "147.0.1.0"
|
|
|
|
def _hash(self, data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
def _tarball(self) -> str:
|
|
return get_download_url().rsplit("/", 1)[-1]
|
|
|
|
def _mock_fetch(self, manifest: bytes, sig: bytes):
|
|
"""httpx.get stub: returns the .sig for *.sig URLs, manifest otherwise."""
|
|
|
|
def mock_get(url, **kwargs):
|
|
resp = MagicMock()
|
|
resp.raise_for_status = MagicMock()
|
|
resp.content = sig if url.endswith(".sig") else manifest
|
|
return resp
|
|
|
|
return mock_get
|
|
|
|
def test_valid_pro_manifest_passes(self, tmp_path):
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real pro binary")
|
|
manifest = (
|
|
f"version={self.PRO_VERSION}\n"
|
|
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
|
).encode()
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download.httpx.get",
|
|
side_effect=self._mock_fetch(manifest, sig),
|
|
),
|
|
):
|
|
_verify_pro_download(archive, self.PRO_VERSION) # no raise
|
|
|
|
def test_skip_checksum_does_not_bypass(self, tmp_path):
|
|
"""CLOAKBROWSER_SKIP_CHECKSUM must NOT weaken Pro verification (the point)."""
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"a malicious pro binary") # bytes differ from manifest
|
|
manifest = (
|
|
f"version={self.PRO_VERSION}\n"
|
|
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
|
).encode()
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch.dict(os.environ, {"CLOAKBROWSER_SKIP_CHECKSUM": "true"}),
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download.httpx.get",
|
|
side_effect=self._mock_fetch(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
|
_verify_pro_download(archive, self.PRO_VERSION)
|
|
|
|
def test_missing_manifest_is_transient_not_tampering(self, tmp_path):
|
|
"""A failed manifest FETCH is transient (router falls back to free), so it
|
|
must be a plain RuntimeError — NOT a BinaryVerificationError, which the
|
|
router re-raises as a hard failure."""
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"x")
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("404")):
|
|
with pytest.raises(RuntimeError) as ei:
|
|
_verify_pro_download(archive, self.PRO_VERSION)
|
|
assert not isinstance(ei.value, BinaryVerificationError)
|
|
|
|
def test_wrong_version_fails_downgrade(self, tmp_path):
|
|
"""A genuinely-signed Pro manifest for a DIFFERENT version is rejected."""
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real pro binary")
|
|
manifest = (
|
|
f"version=1.0.0.0\n" # declares old version, we ask for PRO_VERSION
|
|
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
|
).encode()
|
|
sig = _sign(priv, manifest)
|
|
with (
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download.httpx.get",
|
|
side_effect=self._mock_fetch(manifest, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="Version mismatch"):
|
|
_verify_pro_download(archive, self.PRO_VERSION)
|
|
|
|
def test_tampered_manifest_fails_signature(self, tmp_path):
|
|
"""A manifest tampered after signing fails the signature gate (not the hash)."""
|
|
priv, pub_b64 = _make_key()
|
|
archive = tmp_path / "binary"
|
|
archive.write_bytes(b"the real pro binary")
|
|
manifest = (
|
|
f"version={self.PRO_VERSION}\n"
|
|
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
|
).encode()
|
|
sig = _sign(priv, manifest)
|
|
tampered = manifest.replace(self._tarball().encode(), b"evil.tar.gz")
|
|
with (
|
|
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]),
|
|
patch(
|
|
"cloakbrowser.download.httpx.get",
|
|
side_effect=self._mock_fetch(tampered, sig),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="signature verification failed"):
|
|
_verify_pro_download(archive, self.PRO_VERSION)
|
|
|
|
|
|
class TestProDownloadVersionPinned:
|
|
"""The Pro download must request the explicit version, NOT /latest, so the
|
|
served artifact matches the version-pinned signed manifest it's verified
|
|
against (no latest-advances TOCTOU)."""
|
|
|
|
def test_download_url_is_version_pinned(self):
|
|
from cloakbrowser.config import DOWNLOAD_BASE_URL
|
|
|
|
captured = {}
|
|
|
|
def fake_download_file(url, dest, headers=None):
|
|
captured["url"] = url
|
|
|
|
with (
|
|
patch(
|
|
"cloakbrowser.download._download_file", side_effect=fake_download_file
|
|
),
|
|
patch("cloakbrowser.download._verify_pro_download"),
|
|
patch("cloakbrowser.download._extract_archive"),
|
|
):
|
|
_download_pro_binary("147.0.1.0", "cb_key")
|
|
|
|
assert captured["url"] == f"{DOWNLOAD_BASE_URL}/api/download/147.0.1.0"
|
|
assert not captured["url"].endswith("/latest")
|
|
|
|
|
|
class TestVersionBinding:
|
|
"""The 'version=<v>' line: read by new wrappers, ignored by old parsers."""
|
|
|
|
def test_parse_manifest_version(self):
|
|
manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n"
|
|
assert _parse_manifest_version(manifest) == "146.0.7680.177.5"
|
|
|
|
def test_parse_manifest_version_absent(self):
|
|
assert _parse_manifest_version("abc cloakbrowser-linux-x64.tar.gz\n") is None
|
|
|
|
def test_old_checksum_parser_ignores_version_line(self):
|
|
"""Regression: the version line must not pollute the old hash map."""
|
|
h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
manifest = f"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n"
|
|
result = _parse_checksums(manifest)
|
|
assert result == {"cloakbrowser-linux-x64.tar.gz": h}
|
|
|
|
|
|
class TestFetchSignedManifest:
|
|
"""_fetch_signed_manifest pairs SHA256SUMS + .sig from the same origin."""
|
|
|
|
def test_fetches_both_from_primary(self):
|
|
def mock_get(url, **kwargs):
|
|
resp = MagicMock()
|
|
resp.raise_for_status = MagicMock()
|
|
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
|
|
return resp
|
|
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
|
result = _fetch_signed_manifest("1.2.3.4")
|
|
assert result == (b"MANIFEST", b"SIG")
|
|
|
|
def test_falls_back_to_github_when_primary_missing_sig(self):
|
|
def mock_get(url, **kwargs):
|
|
resp = MagicMock()
|
|
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
|
|
if "cloakbrowser.dev" in url and url.endswith(".sig"):
|
|
resp.raise_for_status.side_effect = Exception("404")
|
|
else:
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
|
result = _fetch_signed_manifest("1.2.3.4")
|
|
assert result == (b"MANIFEST", b"SIG")
|
|
|
|
def test_returns_none_when_all_fail(self):
|
|
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network")):
|
|
assert _fetch_signed_manifest("1.2.3.4") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Welcome-banner cadence: free re-shows every 3 days, Pro shows once ever.
|
|
# ---------------------------------------------------------------------------
|
|
class TestWelcomeCadence:
|
|
def test_pro_shows_once_then_never(self, tmp_path):
|
|
import time as _time
|
|
from cloakbrowser.download import _welcome_due
|
|
|
|
marker = tmp_path / ".welcome_shown"
|
|
assert _welcome_due(marker, pro=True) is True # absent -> show
|
|
marker.write_text(str(int(_time.time())))
|
|
assert _welcome_due(marker, pro=True) is False # exists -> never again
|
|
|
|
def test_free_reshows_after_interval(self, tmp_path):
|
|
import time as _time
|
|
from cloakbrowser.download import _welcome_due, WELCOME_FREE_INTERVAL
|
|
|
|
marker = tmp_path / ".welcome_shown"
|
|
assert _welcome_due(marker, pro=False) is True # absent -> show
|
|
marker.write_text(str(int(_time.time())))
|
|
assert _welcome_due(marker, pro=False) is False # fresh -> skip
|
|
marker.write_text(str(int(_time.time()) - WELCOME_FREE_INTERVAL - 10))
|
|
assert _welcome_due(marker, pro=False) is True # stale -> show again
|
|
|
|
def test_legacy_empty_marker(self, tmp_path):
|
|
from cloakbrowser.download import _welcome_due
|
|
|
|
marker = tmp_path / ".welcome_shown"
|
|
marker.write_text("") # pre-cadence empty marker
|
|
assert _welcome_due(marker, pro=False) is True # unparseable -> free re-shows
|
|
assert _welcome_due(marker, pro=True) is False # pro: existence = already shown
|