Model recommendation keyed to the installed build and free memory, fork-sourced Void templates, signed xbps release workflow on Gitea with the pinned repository key, niri injection guard and wtype-first order, tightened naming sweep, web copy fixes, and CI security and duplication fixes.
448 lines
19 KiB
Python
448 lines
19 KiB
Python
"""Offline policy guards for the Void application template (ticket #27).
|
|
|
|
Spec #16 ships Voculinux itself as a Void package: an in-repo xbps
|
|
template in void-packages layout under ``packaging/void`` builds the app
|
|
from the canonical, checksum-verified release source distribution,
|
|
depends on Void's own interpreter, GTK, desktop bindings and packaged
|
|
Python libraries plus the dependency templates from tickets #25 and #26,
|
|
and installs the renamed desktop entry and icons. Speech models are not
|
|
package payload, nothing is vendored, and no pip runs at build or
|
|
install time.
|
|
|
|
Independent sources of truth: ``src/vocalinux/version.py`` owns the
|
|
version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in``
|
|
plus the renamed ``voculinux.desktop`` own the install paths, and the
|
|
GitHub release publish path (``releases/download``) plus ``xbps-src``
|
|
conventions own the fetch shape. The template must agree with all of
|
|
them; the container smoke gate (ticket #28) builds on top.
|
|
"""
|
|
|
|
import re
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
VOID = REPO_ROOT / "packaging" / "void"
|
|
SRC = VOID / "srcpkgs"
|
|
TEMPLATE = SRC / "voculinux" / "template"
|
|
INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg"
|
|
PINS = VOID / "tool_checksums.txt"
|
|
BUILD_SH = VOID / "build.sh"
|
|
CONTAINER_MAIN = VOID / "container-main.sh"
|
|
VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py"
|
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml"
|
|
DISTRO_DOC = REPO_ROOT / "docs" / "DISTRO_COMPATIBILITY.md"
|
|
|
|
#: pip name (lowercase, dashes) -> the Void binary package providing it.
|
|
#: pywhispercpp/pynput/PyAudio come from the ticket #25/#26 templates in
|
|
#: this same tree; the rest are Void's own packages.
|
|
PIP_TO_VOID = {
|
|
"pywhispercpp": "python3-pywhispercpp",
|
|
"pynput": "python3-pynput",
|
|
"evdev": "python3-evdev",
|
|
"requests": "python3-requests",
|
|
"pysocks": "python3-pysocks",
|
|
"numpy": "python3-numpy",
|
|
"pyaudio": "python3-PyAudio",
|
|
"pygobject": "python3-gobject",
|
|
"psutil": "python3-psutil",
|
|
}
|
|
|
|
#: Void packages beyond the direct pip mapping: pywhispercpp's own
|
|
#: install_requires (tqdm, platformdirs), pynput's X11 backend (xlib),
|
|
#: the directly imported cairo binding, and the desktop substrate.
|
|
EXTRA_VOID_DEPS = (
|
|
"python3-tqdm",
|
|
"python3-platformdirs",
|
|
"python3-xlib",
|
|
"python3-cairo",
|
|
"python3",
|
|
"gtk+3",
|
|
"libayatana-appindicator",
|
|
"gobject-introspection",
|
|
)
|
|
|
|
#: Command-line helpers the app runs at runtime, and the Void package each
|
|
#: comes from. The release gate needs them on a packaged install (ticket
|
|
#: #27; spec #16 "Void packaging"). ydotool is deliberately absent: it is not
|
|
#: packaged for Void, so its fallback rung does not exist there.
|
|
RUNTIME_HELPER_PACKAGES = {
|
|
"pipewire": "pw-record and pw-dump for PipeWire capture",
|
|
"wtype": "direct keysym typing, the first rung on niri",
|
|
"wl-clipboard": "wl-copy and wl-paste for the clipboard paths",
|
|
"xdotool": "X11 and XWayland injection",
|
|
"pulseaudio-utils": "pactl source listing",
|
|
}
|
|
|
|
#: The checksum the template may carry before the fork's release exists. It is
|
|
#: explicit and fails closed in xbps-src; it is never the upstream digest.
|
|
PENDING_CHECKSUM = "PENDING_FORK_RELEASE_SHA256"
|
|
|
|
#: sha256 of the upstream 0.18.5 archive. The template must never pin it: the
|
|
#: package is the fork's own release, not a copy of upstream's.
|
|
UPSTREAM_0_18_5_SHA256 = "1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d"
|
|
|
|
#: The fork's release host. Every distfile and the homepage live here.
|
|
FORK_RELEASE_HOST = "https://git.bongbetic.com/xavierk/voculinux"
|
|
|
|
#: Every package name the app template may reference, verified to exist in
|
|
#: void-packages master on 2026-11-06 via the srcpkgs/<name>/template
|
|
#: paths (python3-devel is a subpackage of the python3 template, as
|
|
#: recorded for ticket #25). Re-verify with `xbps-query -Rs <name>` on a
|
|
#: Void system before extending this set.
|
|
VERIFIED_VOID_PACKAGES = frozenset(
|
|
{
|
|
"python3",
|
|
"python3-devel",
|
|
"python3-setuptools",
|
|
"python3-gobject",
|
|
"python3-cairo",
|
|
"gtk+3",
|
|
"libayatana-appindicator",
|
|
"gobject-introspection",
|
|
"pipewire",
|
|
"wtype",
|
|
"wl-clipboard",
|
|
"xdotool",
|
|
"pulseaudio-utils",
|
|
"python3-numpy",
|
|
"python3-requests",
|
|
"python3-pysocks",
|
|
"python3-psutil",
|
|
"python3-evdev",
|
|
"python3-xlib",
|
|
"python3-tqdm",
|
|
"python3-platformdirs",
|
|
"python3-PyAudio",
|
|
"python3-pynput",
|
|
"python3-pywhispercpp",
|
|
}
|
|
)
|
|
|
|
#: Template options that switch off dependency verification. The app
|
|
#: resolves every dependency deliberately through the mapping above,
|
|
#: never by hiding it behind one of these.
|
|
FORBIDDEN_VARS = ("noverifypydeps", "nopyprovides")
|
|
|
|
|
|
def _template_text() -> str:
|
|
"""Read the raw template file."""
|
|
assert TEMPLATE.is_file(), f"missing Void app template: {TEMPLATE}"
|
|
return TEMPLATE.read_text(encoding="utf-8")
|
|
|
|
|
|
def _template_vars() -> dict:
|
|
"""Parse top-level VAR=value assignments from the template file."""
|
|
variables = {}
|
|
in_function = False
|
|
for line in _template_text().splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if re.fullmatch(r"[A-Za-z0-9_+-]+\(\)\s*\{", stripped):
|
|
in_function = True
|
|
continue
|
|
if in_function:
|
|
if stripped == "}":
|
|
in_function = False
|
|
continue
|
|
key, separator, value = stripped.partition("=")
|
|
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
continue
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
variables[key] = value
|
|
return variables
|
|
|
|
|
|
def _code_lines() -> str:
|
|
"""Template with comment lines stripped; behaviour lives in code."""
|
|
return "\n".join(
|
|
line for line in _template_text().splitlines() if not line.lstrip().startswith("#")
|
|
)
|
|
|
|
|
|
def _post_install_body() -> str:
|
|
"""Shell lines inside post_install(), without comments."""
|
|
lines = _template_text().splitlines()
|
|
start = next(i for i, line in enumerate(lines) if line.strip().startswith("post_install()"))
|
|
body = []
|
|
for line in lines[start + 1 :]:
|
|
if line.strip() == "}":
|
|
break
|
|
if line.strip() and not line.lstrip().startswith("#"):
|
|
body.append(line.strip())
|
|
return "\n".join(body)
|
|
|
|
|
|
def _app_version() -> str:
|
|
"""Version owned by src/vocalinux/version.py."""
|
|
match = re.search(r'^__version__ = "([^"]+)"', VERSION_PY.read_text(encoding="utf-8"), re.M)
|
|
assert match, "version.py no longer defines __version__"
|
|
return match.group(1)
|
|
|
|
|
|
def _project_dependencies() -> list:
|
|
"""Full dependency clauses from pyproject.toml."""
|
|
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
|
return list(data["project"]["dependencies"])
|
|
|
|
|
|
def test_app_template_lives_in_void_packages_layout() -> None:
|
|
"""The app template sits where xbps-src expects it: srcpkgs/<pkgname>."""
|
|
assert TEMPLATE.is_file(), f"{TEMPLATE} is missing"
|
|
assert INSTALL_MSG.is_file(), f"{INSTALL_MSG} is missing"
|
|
|
|
|
|
def test_pkgname_matches_template_directory() -> None:
|
|
"""Void requires pkgname to match srcpkgs/<pkgname>, using the new name."""
|
|
assert _template_vars()["pkgname"] == TEMPLATE.parent.name == "voculinux"
|
|
|
|
|
|
def test_mandatory_void_variables_are_present() -> None:
|
|
"""The Manual's mandatory variables plus fetch inputs must be set."""
|
|
variables = _template_vars()
|
|
for key in (
|
|
"pkgname",
|
|
"version",
|
|
"revision",
|
|
"short_desc",
|
|
"maintainer",
|
|
"license",
|
|
"homepage",
|
|
"build_style",
|
|
"hostmakedepends",
|
|
"makedepends",
|
|
"depends",
|
|
"distfiles",
|
|
"checksum",
|
|
):
|
|
assert variables.get(key), f"template is missing {key}"
|
|
# The release sdist ships no setup.py (setuptools pyproject-only), so
|
|
# xbps-src must build the PEP 517 wheel, not run setup.py directly.
|
|
assert variables["build_style"] == "python3-pep517"
|
|
assert "python3-setuptools" in variables["hostmakedepends"].split()
|
|
assert "python3-devel" in variables["makedepends"].split()
|
|
assert variables["revision"] == "1", "new templates start at revision 1"
|
|
|
|
|
|
def test_template_metadata_shape() -> None:
|
|
"""Maintainer, license, homepage and description follow the Manual."""
|
|
variables = _template_vars()
|
|
assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"])
|
|
assert "noreply" not in variables["maintainer"]
|
|
assert variables["license"] == "AGPL-3.0-only"
|
|
assert variables["homepage"] == FORK_RELEASE_HOST
|
|
assert len(variables["short_desc"]) <= 72
|
|
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"])
|
|
|
|
|
|
def test_template_version_matches_version_py() -> None:
|
|
"""The template builds the current source version, not a stale one."""
|
|
assert _template_vars()["version"] == _app_version()
|
|
|
|
|
|
def test_distfiles_sources_the_canonical_release_by_checksum() -> None:
|
|
"""One fork release-asset distfile, checksum-verified; never upstream or git."""
|
|
variables = _template_vars()
|
|
distfiles = variables["distfiles"]
|
|
version = variables["version"]
|
|
assert distfiles == (
|
|
f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/"
|
|
f"{variables['pkgname']}-${{version}}.tar.gz"
|
|
), "distfiles must be the fork's own release sdist for this version"
|
|
assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive"
|
|
for moving in ("/master", "/main", "/archive/", "git+"):
|
|
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}"
|
|
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
|
|
# The sdist's top-level directory is pkgname-version, so xbps-src needs no
|
|
# wrksrc override (deprecated; xlint rejects it).
|
|
root = distfiles.rsplit("/", 1)[1].replace("${version}", version)
|
|
assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname"
|
|
assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template"
|
|
|
|
|
|
def test_checksum_is_pinned_to_the_fork_release_or_explicitly_pending() -> None:
|
|
"""A 64-hex digest once the fork release exists; until then, the explicit
|
|
pending marker. Never the upstream digest, never empty."""
|
|
checksum = _template_vars()["checksum"]
|
|
assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive"
|
|
assert (
|
|
re.fullmatch(r"[0-9a-f]{64}", checksum) or checksum == PENDING_CHECKSUM
|
|
), f"checksum must be a sha256 or the explicit pending marker, not {checksum!r}"
|
|
|
|
|
|
def test_every_project_dependency_is_covered() -> None:
|
|
"""pyproject.toml runtime deps plus substrate must all resolve to depends."""
|
|
depends = set(_template_vars()["depends"].split())
|
|
uncovered = []
|
|
for clause in _project_dependencies():
|
|
pip_name = re.match(r"^([A-Za-z0-9._-]+)", clause).group(1).lower().replace("_", "-")
|
|
void_name = PIP_TO_VOID.get(pip_name)
|
|
assert void_name, f"no Void mapping recorded for {pip_name}"
|
|
if void_name not in depends:
|
|
uncovered.append(f"{pip_name} -> {void_name}")
|
|
missing_extra = [name for name in EXTRA_VOID_DEPS if name not in depends]
|
|
assert not uncovered, f"deps with no Void coverage: {uncovered}"
|
|
assert not missing_extra, f"substrate missing from depends: {missing_extra}"
|
|
|
|
|
|
def test_template_dependencies_exist_in_void_packages() -> None:
|
|
"""Template dependency lists name only verified void-packages names."""
|
|
variables = _template_vars()
|
|
referenced = set()
|
|
for key in ("depends", "hostmakedepends", "makedepends", "checkdepends"):
|
|
for token in variables.get(key, "").split():
|
|
if token.startswith("${"):
|
|
continue
|
|
referenced.add(token)
|
|
unknown = sorted(referenced - VERIFIED_VOID_PACKAGES)
|
|
assert not unknown, f"template references unverified packages: {unknown}"
|
|
|
|
|
|
def test_no_stale_indicator_library_name() -> None:
|
|
"""The pre-rename libappindicator names do not exist in void-packages."""
|
|
variables = _template_vars()
|
|
for key in ("depends", "makedepends", "hostmakedepends"):
|
|
for token in variables.get(key, "").split():
|
|
assert token not in ("libappindicator", "libappindicator-gtk3"), (
|
|
f"{key} names {token}, which has no void-packages template; "
|
|
"the Ayatana fork (libayatana-appindicator) is the provider"
|
|
)
|
|
|
|
|
|
def test_no_pip_or_vendored_payload_at_build_or_install() -> None:
|
|
"""The build installs from the sdist; pip never runs, nothing is vendored."""
|
|
code = _code_lines().lower()
|
|
for forbidden in ("pip install", "pip3 install", "pip download", "requirements", ".whl"):
|
|
assert forbidden not in code, f"template runs pip or vendors wheels: {forbidden}"
|
|
assert "venv" not in code
|
|
body = _post_install_body().lower()
|
|
assert "pip" not in body, "post_install must not invoke pip"
|
|
|
|
|
|
def test_package_ships_no_speech_models() -> None:
|
|
"""Speech models download on first run; they are not package payload."""
|
|
code = _code_lines().lower()
|
|
assert "model" not in code, "models are not package payload"
|
|
|
|
|
|
def test_post_install_installs_the_fork_names_without_renaming() -> None:
|
|
"""The fork's sdist already carries the new names, so post_install installs
|
|
them as they are: no sed rewrite and no mv of upstream launchers."""
|
|
body = _post_install_body()
|
|
assert "vlicense LICENSE" in body
|
|
assert "vinstall voculinux.desktop 644 usr/share/applications" in body
|
|
assert "resources/icons/scalable" in body
|
|
assert "usr/share/icons/hicolor/scalable/apps" in body
|
|
assert "usr/share/voculinux/packaged" in body
|
|
for upstream_step in ("sed ", " mv ", "vocalinux"):
|
|
assert (
|
|
upstream_step not in body
|
|
), f"post_install still renames upstream names: {upstream_step}"
|
|
assert "usr/bin/vocalinux" not in _template_text(), "no upstream launcher is promoted"
|
|
|
|
|
|
def test_runtime_helpers_are_declared_and_ibus_is_not() -> None:
|
|
"""Capture, direct typing, the clipboard and X11 helpers come from xbps;
|
|
ibus is excluded because it is not an answer on niri (spec #16)."""
|
|
depends = set(_template_vars()["depends"].split())
|
|
for package, why in RUNTIME_HELPER_PACKAGES.items():
|
|
assert package in depends, f"depends must pull in {package} ({why})"
|
|
assert "ibus" not in depends, "ibus is excluded: it cannot inject on niri"
|
|
assert "ydotool" not in depends, "ydotool is not packaged for Void"
|
|
|
|
|
|
def test_build_fetches_nothing_beyond_the_sdist() -> None:
|
|
"""Only the homepage and the declared distfile may appear as URLs."""
|
|
text = _template_text()
|
|
assert text.count("https://") == 2, "homepage + distfiles are the only URLs a template needs"
|
|
assert "FetchContent" not in text and "ExternalProject" not in text
|
|
|
|
|
|
def test_no_verification_disabling_options() -> None:
|
|
"""Dependency verification stays on; distfiles stay checksum-bound."""
|
|
variables = _template_vars()
|
|
for key in FORBIDDEN_VARS:
|
|
assert key not in variables, f"template sets {key}"
|
|
assert "checksum" in variables, "the distfile must carry a checksum line"
|
|
|
|
|
|
def test_template_style_is_lint_clean() -> None:
|
|
"""Trailing whitespace and a missing final newline fail xlint."""
|
|
raw = TEMPLATE.read_bytes().decode("utf-8")
|
|
for lineno, line in enumerate(raw.splitlines(), 1):
|
|
assert line == line.rstrip(), f"trailing whitespace on line {lineno}"
|
|
assert "\t" not in line or line.startswith("\t"), f"stray tab on line {lineno}"
|
|
assert raw.endswith("\n"), "template must end with a newline"
|
|
|
|
|
|
def test_install_msg_covers_the_xbps_only_facts() -> None:
|
|
"""INSTALL.msg tells the user what xbps cannot express."""
|
|
assert INSTALL_MSG.is_file(), f"missing install message: {INSTALL_MSG}"
|
|
text = INSTALL_MSG.read_text(encoding="utf-8").lower()
|
|
assert "input" in text, "the evdev hotkey backend needs the input group"
|
|
assert "model" in text, "models download on first run"
|
|
for engine in ("vosk", "whisper", "parakeet", "faster-whisper", "onnxruntime"):
|
|
assert engine in text, f"optional-engine status must name {engine}"
|
|
assert "ydotool" in text, "the absent injection helper must be named"
|
|
assert "no permission" in text, "niri first run has no permission prompt"
|
|
assert "wtype" in text, "niri types direct through wtype"
|
|
assert "ibus" not in text, "IBus is excluded; the message must not point at it"
|
|
sentences = re.split(r"(?<=\.)\s+", " ".join(text.split()))
|
|
niri_sentences = [sentence for sentence in sentences if "niri" in sentence]
|
|
assert niri_sentences, "the message must describe the niri first run"
|
|
assert not any(
|
|
"portal" in sentence for sentence in niri_sentences
|
|
), "the portal is never recommended on niri"
|
|
|
|
|
|
def test_install_msg_line_length() -> None:
|
|
"""The Manual asks for at most 80 columns per line in INSTALL.msg."""
|
|
raw = INSTALL_MSG.read_bytes().decode("utf-8")
|
|
for lineno, line in enumerate(raw.splitlines(), 1):
|
|
assert len(line) <= 80, f"line {lineno} exceeds 80 columns"
|
|
assert raw.endswith("\n"), "INSTALL.msg must end with a newline"
|
|
|
|
|
|
def test_container_harness_covers_the_app_package() -> None:
|
|
"""build.sh stays podman-only; container-main smokes the installed app."""
|
|
build = BUILD_SH.read_text(encoding="utf-8")
|
|
assert "podman" in build, "the build harness must use podman"
|
|
assert "docker" not in build.lower().replace("never docker", "")
|
|
harness = CONTAINER_MAIN.read_text(encoding="utf-8")
|
|
assert "voculinux --version" in harness, "the app smoke checks the entry point"
|
|
assert "vocalinux.ui.tray_indicator" in harness, "the smoke imports the desktop modules"
|
|
assert (
|
|
"vocalinux.speech_recognition.recognition_manager" in harness
|
|
), "the smoke imports the speech modules"
|
|
assert "usr/share/applications/voculinux.desktop" in harness
|
|
assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness
|
|
assert "usr/share/voculinux/packaged" in harness
|
|
assert (
|
|
"no pre-rename leftovers" in harness
|
|
), "the payload check must reject pre-rename file names in the package"
|
|
assert "python3-pywhispercpp" in harness or "PKGNAME" in harness
|
|
|
|
|
|
def test_void_distro_inventory_matches_void_packages_reality() -> None:
|
|
"""The installer inventory names the Ayatana provider and marks ydotool absent."""
|
|
text = DISTRO_MAP.read_text(encoding="utf-8")
|
|
void_block = text.split(" void:", 1)[1].split("\n solus:", 1)[0]
|
|
code = "\n".join(line for line in void_block.splitlines() if not line.lstrip().startswith("#"))
|
|
assert "libayatana-appindicator" in code
|
|
assert "libappindicator-gtk3" not in code
|
|
assert "libappindicator " not in code.replace("libayatana-appindicator", "")
|
|
assert "absent" in void_block, "the ydotool absence must be marked in the inventory"
|
|
|
|
|
|
def test_void_install_docs_name_the_ayatana_provider() -> None:
|
|
"""The Void install snippet must install a package that exists."""
|
|
text = DISTRO_DOC.read_text(encoding="utf-8")
|
|
void_section = text.split("### Void Linux", 1)[1].split("###", 1)[0]
|
|
assert "libayatana-appindicator" in void_section
|
|
assert "libappindicator-gtk3" not in void_section
|
|
assert "libappindicator " not in void_section.replace("libayatana-appindicator", "")
|