fix(update): check the Voculinux release server, never upstream
Source installs look up releases on the Gitea repository (git.bongbetic.com/xavierk/voculinux) through its JSON API and parse tag_name, html_url and body. The GitHub API and the HTML scrape fallback are removed, and only that Gitea repository's release pages are trusted links. Packaged installs (the xbps marker) make no release request at all: fetch_latest_release returns before importing requests, and Settings -> About shows that updates arrive via sudo xbps-install -Su. Refs #16, #27
This commit is contained in:
+3
-1
@@ -4,7 +4,9 @@ How to upgrade an existing install, plus release notes for the current series.
|
||||
|
||||
## Quick update
|
||||
|
||||
Voculinux checks GitHub Releases in the background about every six hours (and shortly after startup). When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
|
||||
Voculinux checks its own release server, the Gitea repository at `git.bongbetic.com/xavierk/voculinux`, in the background about every six hours (and shortly after startup). Upstream VocaHQ is never queried. When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
|
||||
|
||||
A package-managed install (the Void `voculinux` package) makes no update check at all. Its updates arrive with `sudo xbps-install -Su`, and Settings -> About says so.
|
||||
|
||||
### Official installer
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm"
|
||||
VOCAHQ_X_URL = "https://x.com/vocahq"
|
||||
VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com"
|
||||
|
||||
# About page links that are not GitHub release URLs.
|
||||
# About page links that are not release URLs.
|
||||
_ABOUT_OPEN_URLS = frozenset(
|
||||
{
|
||||
VOCULINUX_SITE_URL,
|
||||
@@ -169,7 +169,7 @@ _VOCAHQ_FAMILY_LINKS = (
|
||||
|
||||
|
||||
def _can_open_url(url: str) -> bool:
|
||||
"""Return True for GitHub project URLs or the About page allowlist."""
|
||||
"""Return True for this project's release URLs or the About page allowlist."""
|
||||
return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ class AboutSectionMixin(Gtk.Dialog):
|
||||
self.check_updates_btn.get_style_context().add_class("suffix-button")
|
||||
_style_action_button(self.check_updates_btn, width=80)
|
||||
self.check_updates_btn.set_tooltip_text(
|
||||
"Look up the latest release for this channel on GitHub"
|
||||
"Look up the latest Voculinux release for this channel"
|
||||
)
|
||||
self.check_updates_btn.connect("clicked", on_check_updates_clicked)
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ from ..utils.model_choice import (
|
||||
priority_for_size,
|
||||
size_for_priority,
|
||||
)
|
||||
from ..utils.packaging import ( # noqa: E402
|
||||
PACKAGED_UPDATE_HINT,
|
||||
is_packaged_install,
|
||||
)
|
||||
from ..utils.paths import models_dir # noqa: E402
|
||||
from ..utils.proxy import ( # noqa: E402
|
||||
DEFAULT_PROXY_MODE,
|
||||
@@ -6542,13 +6546,17 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self._open_web_url(self._about_release_url)
|
||||
|
||||
def _start_update_check(self):
|
||||
"""Kick off a background GitHub release lookup."""
|
||||
"""Kick off a background release lookup, or explain packaged updates."""
|
||||
channel = self._current_update_channel()
|
||||
if is_packaged_install():
|
||||
# Packaged installs never query a release server (ticket #27).
|
||||
self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)
|
||||
return
|
||||
self._update_check_generation += 1
|
||||
generation = self._update_check_generation
|
||||
self.check_updates_btn.set_sensitive(False)
|
||||
self.check_updates_btn.set_label("…")
|
||||
self.update_status_row.set_subtitle(f"Checking GitHub ({channel})…")
|
||||
self.update_status_row.set_subtitle(f"Checking Voculinux releases ({channel})…")
|
||||
|
||||
# Invalidate any in-flight result; that worker will restart on completion.
|
||||
if self._update_check_in_progress:
|
||||
|
||||
@@ -335,7 +335,7 @@ class TrayIndicator:
|
||||
self._model_keepalive.start()
|
||||
self._model_keepalive.bump()
|
||||
|
||||
# Background GitHub update checks (tray menu + optional notification)
|
||||
# Background update checks (tray menu + optional notification)
|
||||
self._update_monitor = UpdateMonitor(
|
||||
get_enabled=self._update_checks_enabled,
|
||||
on_result=self._on_update_check_result,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Facts about installs managed by a system package manager.
|
||||
|
||||
Ticket #27 (Void app template): the xbps template installs an empty
|
||||
``usr/share/voculinux/packaged`` marker. When it exists, background
|
||||
update checks stay available as a signal, but every user-visible update
|
||||
affordance presents the package-manager path instead of release
|
||||
downloads, so packaged users are never misdirected away from
|
||||
package-manager updates.
|
||||
``usr/share/voculinux/packaged`` marker. When it exists, the install never
|
||||
queries a release server (``fetch_latest_release`` returns None without a
|
||||
network request), and the update affordances say that updates arrive via
|
||||
``xbps-install -Su``, never a release download.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -14,10 +13,12 @@ from typing import Optional
|
||||
#: Installed by the xbps template's post_install; absent everywhere else.
|
||||
PACKAGED_MARKER_PATH = Path("/usr/share/voculinux/packaged")
|
||||
|
||||
#: The Void command that delivers updates to a packaged install.
|
||||
PACKAGED_UPDATE_COMMAND = "sudo xbps-install -Su"
|
||||
|
||||
#: Presented wherever a packaged install would otherwise offer a download.
|
||||
PACKAGED_UPDATE_HINT = (
|
||||
"Installed from a system package: update with your package manager "
|
||||
"(on Void: sudo xbps-install -Syu voculinux)."
|
||||
f"Installed from a system package: updates arrive via {PACKAGED_UPDATE_COMMAND}."
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +39,7 @@ def update_menu_tooltip(*, packaged: bool) -> str:
|
||||
if packaged:
|
||||
return (
|
||||
"Open Settings → About for release notes; "
|
||||
"update with your package manager (on Void: sudo xbps-install -Syu voculinux)"
|
||||
f"updates arrive via {PACKAGED_UPDATE_COMMAND}"
|
||||
)
|
||||
return "Open Settings → About for release notes and download links"
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Check GitHub Releases for newer Voculinux versions."""
|
||||
"""Check the Voculinux release server (Gitea) for newer versions.
|
||||
|
||||
Voculinux is a fork: its releases live on the personal Gitea instance. The
|
||||
upstream VocaHQ repository is never queried, and packaged installs never query
|
||||
a release server at all (their updates arrive through xbps).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -8,7 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from .. import version as _version_module
|
||||
from ..app_identity import UPDATE_CHECKER_USER_AGENT
|
||||
from ..version import __url__
|
||||
from .packaging import is_packaged_install
|
||||
from .proxy import requests_proxies
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,7 +22,12 @@ UPDATE_CHANNELS = ("stable", "nightly")
|
||||
DEFAULT_UPDATE_CHANNEL = "stable"
|
||||
DEFAULT_CHECK_AUTOMATICALLY = True
|
||||
|
||||
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
|
||||
#: Release source for source installs: this fork's repository on Gitea.
|
||||
UPDATE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux"
|
||||
|
||||
_REPO_URL_RE = re.compile(
|
||||
r"^https://(?P<host>[^/]+)/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
||||
)
|
||||
_PRE_RELEASE_SUFFIX_RE = re.compile(
|
||||
r"^(?P<main>\d+(?:\.\d+)*)(?:[-.](?P<pre>(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
|
||||
re.IGNORECASE,
|
||||
@@ -29,7 +39,7 @@ _DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
"""Metadata for a GitHub release."""
|
||||
"""Metadata for a release published on the release server."""
|
||||
|
||||
tag_name: str
|
||||
version: str
|
||||
@@ -47,16 +57,17 @@ def normalize_channel(channel: Optional[str]) -> str:
|
||||
return value if value in UPDATE_CHANNELS else DEFAULT_UPDATE_CHANNEL
|
||||
|
||||
|
||||
def _repo_parts(repo_url: str = __url__) -> tuple[str, str]:
|
||||
match = _GITHUB_REPO_RE.match(repo_url.strip())
|
||||
def _repo_parts(repo_url: str = UPDATE_REPO_URL) -> tuple[str, str, str]:
|
||||
"""Return ``(host, owner, repo)`` for an https Gitea repository URL."""
|
||||
match = _REPO_URL_RE.match(repo_url.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Unsupported repository URL: {repo_url}")
|
||||
return match.group("owner"), match.group("repo")
|
||||
return match.group("host"), match.group("owner"), match.group("repo")
|
||||
|
||||
|
||||
def _repo_api_base(repo_url: str = __url__) -> str:
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
return f"https://api.github.com/repos/{owner}/{repo}"
|
||||
def _repo_api_base(repo_url: str = UPDATE_REPO_URL) -> str:
|
||||
host, owner, repo = _repo_parts(repo_url)
|
||||
return f"https://{host}/api/v1/repos/{owner}/{repo}"
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
@@ -139,44 +150,24 @@ def is_update_available(
|
||||
return is_newer_version(current, latest.version)
|
||||
|
||||
|
||||
# github.com/jatinkrmalik/vocalinux redirects here after the org transfer.
|
||||
_VOCULINUX_GITHUB_OWNERS = frozenset({"VocaHQ", "jatinkrmalik"})
|
||||
|
||||
|
||||
def _trusted_path_prefixes(repo_url: str = __url__) -> tuple[str, ...]:
|
||||
"""Return GitHub path prefixes that belong to this project."""
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
prefixes = [f"/{owner}/{repo}"]
|
||||
known_owners = {name.casefold() for name in _VOCULINUX_GITHUB_OWNERS}
|
||||
if repo.casefold() == "vocalinux" and owner.casefold() in known_owners:
|
||||
prefixes.extend(f"/{name}/vocalinux" for name in sorted(_VOCULINUX_GITHUB_OWNERS))
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for prefix in prefixes:
|
||||
key = prefix.casefold()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(prefix)
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def is_trusted_release_url(url: str, repo_url: str = __url__) -> bool:
|
||||
"""Return True when ``url`` points at this project's GitHub pages."""
|
||||
def is_trusted_release_url(url: str, repo_url: str = UPDATE_REPO_URL) -> bool:
|
||||
"""Return True when ``url`` points at this project's release pages on Gitea."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
host, owner, repo = _repo_parts(repo_url)
|
||||
parsed = urlparse(url)
|
||||
prefixes = _trusted_path_prefixes(repo_url)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
|
||||
if parsed.scheme != "https" or parsed.netloc.lower() != host.lower():
|
||||
return False
|
||||
path = parsed.path.rstrip("/")
|
||||
return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
|
||||
path = parsed.path.rstrip("/").casefold()
|
||||
prefix = f"/{owner}/{repo}".casefold()
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _release_from_payload(
|
||||
data: dict, channel: str, repo_url: str = __url__
|
||||
data: dict, channel: str, repo_url: str = UPDATE_REPO_URL
|
||||
) -> Optional[ReleaseInfo]:
|
||||
tag_name = data.get("tag_name") or ""
|
||||
if not tag_name:
|
||||
@@ -199,71 +190,26 @@ def _release_from_payload(
|
||||
)
|
||||
|
||||
|
||||
def _html_url_from_relative_update_url(update_url: object, repo_url: str) -> str:
|
||||
"""Return a trusted tag URL from a relative GitHub ``update_url``, or empty."""
|
||||
if not isinstance(update_url, str):
|
||||
return ""
|
||||
if not update_url.startswith("/") or update_url.startswith("//"):
|
||||
return ""
|
||||
if ".." in update_url or "\\" in update_url or "?" in update_url or "#" in update_url:
|
||||
return ""
|
||||
if ":" in update_url or any(ch.isspace() for ch in update_url):
|
||||
return ""
|
||||
candidate = "https://github.com" + update_url
|
||||
if not is_trusted_release_url(candidate, repo_url):
|
||||
return ""
|
||||
return candidate
|
||||
|
||||
|
||||
def _fetch_stable_from_releases_page(
|
||||
requests, repo_url: str, timeout: float
|
||||
) -> Optional[ReleaseInfo]:
|
||||
"""Look up the latest stable tag from github.com when the REST API is blocked."""
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
url = f"https://github.com/{owner}/{repo}/releases/latest"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UPDATE_CHECKER_USER_AGENT,
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies())
|
||||
final_url = getattr(response, "url", "") or ""
|
||||
parsed = urlparse(final_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or parsed.netloc.lower() != "github.com"
|
||||
or not is_trusted_release_url(final_url, repo_url)
|
||||
or response.status_code != 200
|
||||
):
|
||||
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
|
||||
return None
|
||||
data = response.json()
|
||||
if not isinstance(data, dict) or not (data.get("tag_name") or ""):
|
||||
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
|
||||
return None
|
||||
html_url = _html_url_from_relative_update_url(data.get("update_url"), repo_url)
|
||||
return _release_from_payload(
|
||||
{"tag_name": data.get("tag_name"), "html_url": html_url},
|
||||
"stable",
|
||||
repo_url,
|
||||
)
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError) as exc:
|
||||
logger.debug("Stable release HTML fallback failed for %s: %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_release(
|
||||
repo_url: str = __url__,
|
||||
repo_url: str = UPDATE_REPO_URL,
|
||||
timeout: float = 10.0,
|
||||
channel: str = DEFAULT_UPDATE_CHANNEL,
|
||||
) -> Optional[ReleaseInfo]:
|
||||
"""Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
|
||||
"""Fetch the newest Gitea release for ``channel`` (``stable`` or ``nightly``).
|
||||
|
||||
A packaged install returns None without importing ``requests`` or opening a
|
||||
connection: its updates arrive through the system package manager.
|
||||
"""
|
||||
if is_packaged_install():
|
||||
logger.debug("Packaged install: release check skipped, updates come from xbps")
|
||||
return None
|
||||
|
||||
import requests
|
||||
|
||||
channel = normalize_channel(channel)
|
||||
api_base = _repo_api_base(repo_url)
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UPDATE_CHECKER_USER_AGENT,
|
||||
}
|
||||
|
||||
@@ -279,7 +225,8 @@ def fetch_latest_release(
|
||||
logger.warning("No latest release found for %s", api_base)
|
||||
return None
|
||||
if response.status_code in (403, 429):
|
||||
return _fetch_stable_from_releases_page(requests, repo_url, timeout)
|
||||
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
@@ -293,12 +240,12 @@ def fetch_latest_release(
|
||||
response = requests.get(
|
||||
f"{api_base}/releases",
|
||||
headers=headers,
|
||||
params={"per_page": 30, "page": page},
|
||||
params={"limit": 30, "page": page},
|
||||
timeout=timeout,
|
||||
proxies=requests_proxies(),
|
||||
)
|
||||
if response.status_code in (403, 429):
|
||||
logger.debug("GitHub API rate-limited or forbidden for %s", api_base)
|
||||
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Background GitHub release checks for Voculinux.
|
||||
"""Background release checks for Voculinux.
|
||||
|
||||
Schedules a delayed first check, then repeats on an interval. Networking runs on
|
||||
a worker thread; the result callback is delivered on the GLib main loop.
|
||||
|
||||
@@ -38,8 +38,8 @@ def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None:
|
||||
|
||||
def test_packaged_hint_names_the_package_manager_path() -> None:
|
||||
"""The presented path is xbps, not a release download."""
|
||||
assert "package manager" in PACKAGED_UPDATE_HINT
|
||||
assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT
|
||||
assert "xbps-install -Su" in PACKAGED_UPDATE_HINT
|
||||
assert "updates arrive via sudo xbps-install -Su" in PACKAGED_UPDATE_HINT
|
||||
|
||||
|
||||
def test_unpackaged_menu_label_is_unchanged() -> None:
|
||||
@@ -90,4 +90,4 @@ def test_packaged_updates_description_presents_the_package_manager() -> None:
|
||||
"""The About page tells packaged users to update through xbps."""
|
||||
description = updates_description("stable", packaged=True)
|
||||
assert "stable" in description
|
||||
assert "package manager" in description
|
||||
assert "xbps-install -Su" in description
|
||||
|
||||
@@ -1476,9 +1476,10 @@ class TestAboutPage(unittest.TestCase):
|
||||
VOCAHQ_DISCORD_URL,
|
||||
VOCAHQ_X_URL,
|
||||
VOCAHQ_MAILTO_URL,
|
||||
"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
):
|
||||
self.assertTrue(_can_open_url(url), url)
|
||||
self.assertFalse(_can_open_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0"))
|
||||
self.assertFalse(_can_open_url("https://evil.example/payload"))
|
||||
self.assertFalse(_can_open_url("mailto:evil@example.com"))
|
||||
self.assertFalse(_can_open_url(""))
|
||||
@@ -1644,6 +1645,15 @@ class TestUpdatePreferences(unittest.TestCase):
|
||||
self.assertIn("self._start_update_check()", method)
|
||||
self.assertIn("Automatic checks are off. Press Check to look now.", method)
|
||||
|
||||
def test_packaged_check_now_explains_xbps_before_any_lookup(self) -> None:
|
||||
"""A packaged install's About page shows the xbps path and starts no thread."""
|
||||
method = self._method_source(self.settings_source, "_start_update_check")
|
||||
guard = method.index("if is_packaged_install():")
|
||||
packaged_branch = method[guard : method.index("return", guard)]
|
||||
|
||||
self.assertIn("self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)", packaged_branch)
|
||||
self.assertLess(guard, method.index("threading.Thread"))
|
||||
|
||||
def test_update_switch_loads_state_before_connecting(self) -> None:
|
||||
switch_start = self.about_source.index("self.update_checks_switch = Gtk.Switch()")
|
||||
active_position = self.about_source.index(
|
||||
|
||||
+101
-130
@@ -1,8 +1,10 @@
|
||||
"""Tests for the GitHub Releases update checker."""
|
||||
"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux.utils.update_checker import (
|
||||
ReleaseInfo,
|
||||
build_channel,
|
||||
@@ -72,7 +74,7 @@ class TestChannels:
|
||||
tag_name="v0.15.0",
|
||||
version="0.15.0",
|
||||
name="v0.15.0",
|
||||
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
body="",
|
||||
published_at="2026-08-02T00:00:00Z",
|
||||
prerelease=False,
|
||||
@@ -86,7 +88,7 @@ class TestChannels:
|
||||
tag_name="nightly-2026-08-02",
|
||||
version="nightly-2026-08-02",
|
||||
name="Nightly Build - 0.14.2.dev20260802+27ecf88",
|
||||
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
|
||||
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
||||
body="",
|
||||
published_at="2026-08-02T00:00:00Z",
|
||||
prerelease=True,
|
||||
@@ -103,20 +105,22 @@ class TestChannels:
|
||||
|
||||
class TestTrustedUrls:
|
||||
def test_accepts_project_urls(self):
|
||||
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
|
||||
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0")
|
||||
# Pre-transfer URLs still redirect and must stay trusted.
|
||||
assert is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
|
||||
assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
|
||||
assert is_trusted_release_url(
|
||||
"https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0"
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
|
||||
)
|
||||
|
||||
def test_rejects_untrusted_urls(self):
|
||||
assert not is_trusted_release_url("https://evil.example/payload")
|
||||
assert not is_trusted_release_url("http://github.com/VocaHQ/vocalinux")
|
||||
assert not is_trusted_release_url("https://github.com/evil/voculinux")
|
||||
def test_rejects_upstream_and_other_urls(self):
|
||||
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
|
||||
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
|
||||
assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
|
||||
assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
|
||||
assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
|
||||
assert not is_trusted_release_url("")
|
||||
|
||||
def test_rejects_non_https_urls(self):
|
||||
assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
|
||||
|
||||
|
||||
class TestFormatReleaseNotes:
|
||||
def test_empty_body(self):
|
||||
@@ -150,7 +154,7 @@ class TestFetchLatestRelease:
|
||||
mock_response.json.return_value = {
|
||||
"tag_name": "v0.15.0",
|
||||
"name": "v0.15.0",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
"body": "Notes",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": False,
|
||||
@@ -167,7 +171,7 @@ class TestFetchLatestRelease:
|
||||
assert release.html_url.endswith("/v0.15.0")
|
||||
mock_requests.get.assert_called_once()
|
||||
api_url = mock_requests.get.call_args[0][0]
|
||||
assert api_url == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
|
||||
assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
||||
|
||||
def test_drops_untrusted_html_url(self):
|
||||
mock_response = MagicMock()
|
||||
@@ -197,7 +201,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "v0.15.0",
|
||||
"name": "v0.15.0",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
"body": "Stable",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": False,
|
||||
@@ -206,7 +210,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "nightly-2026-08-02",
|
||||
"name": "nightly-2026-08-02",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
||||
"body": "Nightly",
|
||||
"published_at": "2026-08-02T05:00:00Z",
|
||||
"prerelease": True,
|
||||
@@ -232,7 +236,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": f"v0.15.{i}",
|
||||
"name": f"v0.15.{i}",
|
||||
"html_url": f"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.{i}",
|
||||
"html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
|
||||
"body": "",
|
||||
"published_at": "2026-08-03T00:00:00Z",
|
||||
"prerelease": False,
|
||||
@@ -244,7 +248,9 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "nightly-2026-08-01",
|
||||
"name": "nightly-2026-08-01",
|
||||
"html_url": ("https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-01"),
|
||||
"html_url": (
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
|
||||
),
|
||||
"body": "Nightly",
|
||||
"published_at": "2026-08-01T05:00:00Z",
|
||||
"prerelease": True,
|
||||
@@ -271,92 +277,45 @@ class TestFetchLatestRelease:
|
||||
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
|
||||
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
|
||||
|
||||
def test_stable_api_403_uses_html_fallback(self, caplog):
|
||||
release = self._fetch_stable_with_rate_limit(403, caplog)
|
||||
assert isinstance(release, ReleaseInfo)
|
||||
assert release.version == "0.17.0"
|
||||
assert release.channel == "stable"
|
||||
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
|
||||
def test_stable_api_429_uses_html_fallback(self, caplog):
|
||||
release = self._fetch_stable_with_rate_limit(429, caplog)
|
||||
assert isinstance(release, ReleaseInfo)
|
||||
assert release.version == "0.17.0"
|
||||
assert release.channel == "stable"
|
||||
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
|
||||
def test_stable_api_403_fallback_failures_return_none_quietly(self, caplog):
|
||||
api_response = _mock_response(403)
|
||||
failures = [
|
||||
_mock_response(
|
||||
500,
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
Exception("offline"),
|
||||
_mock_response(
|
||||
200,
|
||||
json_value=["not", "a", "dict"],
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
_mock_response(
|
||||
200,
|
||||
json_value={"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0"},
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
]
|
||||
def test_stable_404_means_no_release_and_warns(self, caplog):
|
||||
"""A missing or private repo answers 404 anonymously: no release, one warning."""
|
||||
mock_requests = _requests_mock([_mock_response(404)])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for failure in failures:
|
||||
mock_requests = _requests_mock([api_response, failure])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
mock_requests.get.assert_called_once()
|
||||
assert len(_checker_warnings(caplog)) == 1
|
||||
|
||||
def test_evil_update_url_does_not_become_html_url(self, caplog):
|
||||
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
evil_paths = [
|
||||
"/evil/voculinux/releases/tag/v9",
|
||||
"https://evil.example/x",
|
||||
"/VocaHQ/vocalinux/releases/../evil",
|
||||
]
|
||||
@pytest.mark.parametrize("status_code", [403, 429])
|
||||
def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
|
||||
mock_requests = _requests_mock([_mock_response(status_code)])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for update_url in evil_paths:
|
||||
api_response = _mock_response(403)
|
||||
fallback = _mock_response(
|
||||
200,
|
||||
json_value={"tag_name": "v0.17.0", "update_url": update_url},
|
||||
url=tag_url,
|
||||
)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
assert release is not None
|
||||
assert release.version == "0.17.0"
|
||||
assert release.html_url == ""
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
mock_requests.get.assert_called_once()
|
||||
assert _checker_warnings(caplog) == []
|
||||
|
||||
def test_untrusted_fallback_final_url_is_rejected(self, caplog):
|
||||
def test_prerelease_tag_is_compared_not_filtered(self):
|
||||
"""Existing comparison rules apply to a pre-release tag: no new filter."""
|
||||
payload = {
|
||||
"tag_name": "v0.17.0",
|
||||
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"tag_name": "v0.18.0-beta.1",
|
||||
"name": "v0.18.0-beta.1",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
|
||||
"body": "Beta",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": True,
|
||||
}
|
||||
untrusted_urls = (
|
||||
"https://evil.example/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"https://github.com/evil/voculinux/releases/tag/v0.17.0",
|
||||
"http://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
)
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for final_url in untrusted_urls:
|
||||
api_response = _mock_response(403)
|
||||
fallback = _mock_response(200, json_value=payload, url=final_url)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
|
||||
def test_nightly_api_403_returns_none_without_html_fallback(self, caplog):
|
||||
assert release is not None
|
||||
assert release.prerelease is True
|
||||
assert release.version == "0.18.0-beta.1"
|
||||
assert is_update_available("0.17.0", release) is True
|
||||
assert is_update_available("0.18.0", release) is False
|
||||
|
||||
def test_nightly_api_403_returns_none(self, caplog):
|
||||
api_response = _mock_response(403)
|
||||
mock_requests = _requests_mock([api_response])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
@@ -364,46 +323,58 @@ class TestFetchLatestRelease:
|
||||
assert fetch_latest_release(channel="nightly") is None
|
||||
mock_requests.get.assert_called_once()
|
||||
requested = mock_requests.get.call_args[0][0]
|
||||
assert requested == "https://api.github.com/repos/VocaHQ/vocalinux/releases"
|
||||
assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
|
||||
assert _checker_warnings(caplog) == []
|
||||
|
||||
def _fetch_stable_with_rate_limit(self, status_code: int, caplog):
|
||||
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
api_response = _mock_response(status_code)
|
||||
fallback = _mock_response(
|
||||
200,
|
||||
json_value={
|
||||
"tag_name": "v0.17.0",
|
||||
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"id": 1,
|
||||
"edit_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0/edit",
|
||||
"delete_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"update_authenticity_token": "csrf-update",
|
||||
"delete_authenticity_token": "csrf-delete",
|
||||
},
|
||||
url=tag_url,
|
||||
)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
|
||||
"""The stable lookup reads Gitea's JSON and never contacts GitHub."""
|
||||
payload = {
|
||||
"tag_name": "v0.18.6",
|
||||
"name": "v0.18.6",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
|
||||
"body": "Notes",
|
||||
"published_at": "2026-11-06T10:00:00Z",
|
||||
"prerelease": False,
|
||||
}
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
|
||||
assert mock_requests.get.call_count == 2
|
||||
first, second = mock_requests.get.call_args_list
|
||||
assert first[0][0] == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
|
||||
assert first.kwargs["headers"]["Accept"] == "application/vnd.github+json"
|
||||
assert release is not None
|
||||
assert release.tag_name == "v0.18.6"
|
||||
assert release.html_url == payload["html_url"]
|
||||
assert release.body == "Notes"
|
||||
mock_requests.get.assert_called_once()
|
||||
first = mock_requests.get.call_args
|
||||
assert (
|
||||
first[0][0]
|
||||
== "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
||||
)
|
||||
assert first.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
|
||||
assert "Authorization" not in first.kwargs["headers"]
|
||||
assert second[0][0] == "https://github.com/VocaHQ/vocalinux/releases/latest"
|
||||
assert second.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert second.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
|
||||
assert "Authorization" not in second.kwargs["headers"]
|
||||
assert fallback.url == tag_url
|
||||
assert _checker_warnings(caplog) == []
|
||||
assert release is not None
|
||||
assert "csrf-update" not in repr(release)
|
||||
assert "csrf-delete" not in repr(release)
|
||||
return release
|
||||
|
||||
@pytest.mark.parametrize("channel", ["stable", "nightly"])
|
||||
def test_source_install_never_queries_upstream(self, channel):
|
||||
payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
fetch_latest_release(channel=channel)
|
||||
for call in mock_requests.get.call_args_list:
|
||||
assert call[0][0].startswith(
|
||||
"https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
|
||||
)
|
||||
|
||||
def test_packaged_install_makes_no_network_request(self):
|
||||
"""Packaged installs get updates from xbps, so the checker stays off the network."""
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
|
||||
with (
|
||||
patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
|
||||
patch.dict("sys.modules", {"requests": mock_requests}),
|
||||
):
|
||||
assert fetch_latest_release() is None
|
||||
assert fetch_latest_release(channel="nightly") is None
|
||||
mock_requests.get.assert_not_called()
|
||||
|
||||
|
||||
def _mock_response(status_code, json_value=None, url=""):
|
||||
|
||||
Reference in New Issue
Block a user