3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
1341 lines
48 KiB
Python
1341 lines
48 KiB
Python
# SPDX-License-Identifier: MIT
|
||
"""Tests for core.discovery — dynamic Windows app enumeration.
|
||
|
||
Covers:
|
||
- JSON parsing (happy path, malformed, BOM, object-not-array, truncated flag).
|
||
- Entry validation (source allowlist, UWP launch_uri required, length caps,
|
||
base64 decode, oversized icon dropped).
|
||
- Slug generation (collision safety, Unicode, unsafe chars).
|
||
- persist_discovered (writes app.toml, icon magic-byte dispatch, replace=True
|
||
clears old, path-traversal guard in _safe_rmtree).
|
||
- discover_apps end-to-end (subprocess mocking for copy/exec, timeout,
|
||
pod-not-running, script-missing, backend gating).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import struct
|
||
import subprocess
|
||
import zlib
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from winpodx.core.config import Config
|
||
from winpodx.core.discovery import (
|
||
DiscoveredApp,
|
||
DiscoveryError,
|
||
_entry_to_discovered,
|
||
_is_junk_entry,
|
||
_parse_discovery_output,
|
||
_purge_reverse_open_entries,
|
||
_render_app_toml,
|
||
_safe_rmtree,
|
||
_sanitize_start_menu_folder,
|
||
_slugify_name,
|
||
_sniff_icon_ext,
|
||
_validate_png_bytes,
|
||
discover_apps,
|
||
persist_discovered,
|
||
)
|
||
|
||
# --- Helpers for Popen-based discover_apps mocking -------------------------
|
||
|
||
|
||
def _build_png(
|
||
width: int = 1,
|
||
height: int = 1,
|
||
*,
|
||
body_extra: bytes = b"",
|
||
corrupt_crc: bool = False,
|
||
real_idat: bool = True,
|
||
) -> bytes:
|
||
"""Construct a PNG for tests.
|
||
|
||
When ``real_idat=True`` (default), produces a genuinely decodable
|
||
RGBA PNG of the requested dimensions — IDAT is properly
|
||
zlib-compressed and each scanline is prefixed with the filter byte.
|
||
Such a PNG passes both ``QImage.loadFromData`` and the stdlib chunk
|
||
walker, which is what the icon-happy-path tests need now that M1
|
||
requires structural validity before persistence.
|
||
|
||
When ``real_idat=False``, produces the old structurally-valid-but-
|
||
non-decodable shape (correct magic + IHDR + optional garbage IDAT +
|
||
IEND with valid CRCs) — useful for exercising the stdlib chunk
|
||
walker in isolation (which does not try to decompress IDAT).
|
||
|
||
``body_extra`` is only honored when ``real_idat=False``; in real
|
||
mode the IDAT is computed from the declared dimensions.
|
||
``corrupt_crc`` flips the IHDR CRC so the stdlib walker rejects it.
|
||
"""
|
||
|
||
def _chunk(chunk_type: bytes, payload: bytes) -> bytes:
|
||
crc = zlib.crc32(chunk_type + payload) & 0xFFFFFFFF
|
||
if corrupt_crc and chunk_type == b"IHDR":
|
||
crc ^= 0xDEADBEEF
|
||
return struct.pack(">I", len(payload)) + chunk_type + payload + struct.pack(">I", crc)
|
||
|
||
magic = b"\x89PNG\r\n\x1a\n"
|
||
ihdr_payload = struct.pack(">II", width, height) + b"\x08\x06\x00\x00\x00"
|
||
png = magic + _chunk(b"IHDR", ihdr_payload)
|
||
if real_idat:
|
||
# Filter=0 per scanline + RGBA zero pixels; zlib-compressed.
|
||
scanline = b"\x00" + (b"\x00\x00\x00\x00" * width)
|
||
raw = scanline * height
|
||
png += _chunk(b"IDAT", zlib.compress(raw))
|
||
elif body_extra:
|
||
png += _chunk(b"IDAT", body_extra)
|
||
png += _chunk(b"IEND", b"")
|
||
return png
|
||
|
||
|
||
def _fake_popen(stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0) -> MagicMock:
|
||
"""Build a MagicMock Popen that emits fixed bytes then terminates.
|
||
|
||
The helper wires up ``fileno()`` on the stdout/stderr attributes so
|
||
core.discovery._run_bounded's ``os.read(fd, ...)`` loop sees a clean
|
||
EOF after the provided bytes. Uses an OS pipe per stream so the
|
||
drain-thread code path is exercised verbatim.
|
||
"""
|
||
proc = MagicMock(spec=subprocess.Popen)
|
||
|
||
stdout_r, stdout_w = os.pipe()
|
||
stderr_r, stderr_w = os.pipe()
|
||
os.write(stdout_w, stdout)
|
||
os.close(stdout_w)
|
||
os.write(stderr_w, stderr)
|
||
os.close(stderr_w)
|
||
|
||
stdout_file = MagicMock()
|
||
stdout_file.fileno.return_value = stdout_r
|
||
stderr_file = MagicMock()
|
||
stderr_file.fileno.return_value = stderr_r
|
||
|
||
proc.stdout = stdout_file
|
||
proc.stderr = stderr_file
|
||
# Bug A fix: discover_apps now pipes the script body to powershell -Command -
|
||
# via stdin instead of `podman cp`. The Popen mock therefore needs a stdin
|
||
# attribute that accepts .write() + .close() without errors.
|
||
stdin_file = MagicMock()
|
||
stdin_file.write.return_value = None
|
||
stdin_file.close.return_value = None
|
||
proc.stdin = stdin_file
|
||
proc.returncode = returncode
|
||
proc.poll.return_value = returncode
|
||
proc.wait.return_value = returncode
|
||
proc.kill.return_value = None
|
||
return proc
|
||
|
||
|
||
# --- Fixtures --------------------------------------------------------------
|
||
|
||
# Minimal PNG header (8-byte magic) that _sniff_icon_ext recognizes.
|
||
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||
_TINY_PNG = _PNG_MAGIC + b"\x00\x00\x00\rIHDR" + b"\x00" * 20
|
||
_TINY_SVG = b'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>'
|
||
|
||
|
||
def _valid_entry(**overrides) -> dict:
|
||
base = {
|
||
"name": "Example App",
|
||
"path": "C:\\Program Files\\Example\\example.exe",
|
||
"args": "",
|
||
"source": "win32",
|
||
"wm_class_hint": "example",
|
||
"launch_uri": "",
|
||
"icon_b64": "",
|
||
}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
|
||
# --- _sniff_icon_ext -------------------------------------------------------
|
||
|
||
|
||
def test_sniff_icon_ext_png():
|
||
assert _sniff_icon_ext(_TINY_PNG) == "png"
|
||
|
||
|
||
def test_sniff_icon_ext_svg():
|
||
assert _sniff_icon_ext(_TINY_SVG) == "svg"
|
||
|
||
|
||
def test_sniff_icon_ext_svg_with_leading_whitespace():
|
||
# SVG files sometimes have leading whitespace before <?xml or <svg.
|
||
assert _sniff_icon_ext(b" \n" + _TINY_SVG) == "svg"
|
||
|
||
|
||
def test_sniff_icon_ext_empty():
|
||
assert _sniff_icon_ext(b"") == ""
|
||
|
||
|
||
def test_sniff_icon_ext_unknown_format():
|
||
assert _sniff_icon_ext(b"garbage random bytes") == ""
|
||
|
||
|
||
def test_sniff_icon_ext_jpeg_rejected():
|
||
# Only PNG/SVG are accepted; JPEG (FF D8 FF) is not.
|
||
assert _sniff_icon_ext(b"\xff\xd8\xff\xe0" + b"\x00" * 20) == ""
|
||
|
||
|
||
# --- _slugify_name ---------------------------------------------------------
|
||
|
||
|
||
def test_slugify_basic():
|
||
assert _slugify_name("Microsoft Word") == "microsoft-word"
|
||
|
||
|
||
def test_slugify_collapses_non_alnum():
|
||
assert _slugify_name("Foo Bar / Baz") == "foo-bar-baz"
|
||
|
||
|
||
def test_slugify_strips_leading_trailing_punctuation():
|
||
assert _slugify_name(" ---Foo--- ") == "foo"
|
||
|
||
|
||
def test_slugify_pure_unicode_falls_back_to_stable_hash():
|
||
# #553: a name with NO ASCII-safe chars (Chinese/Korean/Japanese app names)
|
||
# must NOT be dropped — it gets a stable, safe `app-<hash>` slug so the app
|
||
# is discoverable (full_name keeps the original characters for display).
|
||
import re
|
||
|
||
safe = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||
for name in ("한글", "东方财富", "同花顺"):
|
||
slug = _slugify_name(name)
|
||
assert slug.startswith("app-")
|
||
assert safe.match(slug)
|
||
# Stable (re-scan doesn't duplicate) and unique across distinct names.
|
||
assert _slugify_name("东方财富") == _slugify_name("东方财富")
|
||
assert _slugify_name("东方财富") != _slugify_name("同花顺")
|
||
# A name with SOME ASCII keeps that part instead of hashing.
|
||
assert _slugify_name("微信WeChat") == "wechat"
|
||
# Genuinely empty / whitespace-only is still rejected (empty slug).
|
||
assert _slugify_name("") == ""
|
||
assert _slugify_name(" ") == ""
|
||
|
||
|
||
def test_slugify_bounds_length():
|
||
assert len(_slugify_name("a" * 200)) <= 64
|
||
|
||
|
||
def test_slugify_rejects_path_traversal():
|
||
# Slashes / backslashes / dots collapse to single '-' and the outer
|
||
# regex enforces [a-zA-Z0-9_-] only; the result is a safe leaf name.
|
||
slug = _slugify_name("../../etc/passwd")
|
||
assert "/" not in slug
|
||
assert ".." not in slug
|
||
assert "\\" not in slug
|
||
|
||
|
||
# --- _entry_to_discovered --------------------------------------------------
|
||
|
||
|
||
def test_entry_happy_path():
|
||
app = _entry_to_discovered(_valid_entry())
|
||
assert app is not None
|
||
assert app.name == "example-app"
|
||
assert app.full_name == "Example App"
|
||
assert app.executable.endswith("example.exe")
|
||
assert app.source == "win32"
|
||
|
||
|
||
def test_entry_missing_name_rejected():
|
||
assert _entry_to_discovered(_valid_entry(name="")) is None
|
||
|
||
|
||
def test_entry_parses_and_filters_url_schemes():
|
||
# #421/#694: valid schemes kept (lowercased, deduped); dangerous + malformed
|
||
# dropped by the shared policy.
|
||
app = _entry_to_discovered(
|
||
_valid_entry(url_schemes=["mailto", "SLACK", "javascript", "bad scheme", "vnc", "mailto:"])
|
||
)
|
||
assert app is not None
|
||
assert app.url_schemes == ["mailto", "slack", "vnc"]
|
||
|
||
|
||
def test_render_app_toml_emits_url_schemes():
|
||
from winpodx.core.discovery import DiscoveredApp
|
||
|
||
app = DiscoveredApp(
|
||
name="outlook",
|
||
full_name="Outlook",
|
||
executable="C:\\o.exe",
|
||
url_schemes=["mailto", "webcal"],
|
||
)
|
||
toml = _render_app_toml(app)
|
||
assert 'url_schemes = ["mailto", "webcal"]' in toml
|
||
# absent when empty (minimal-diff convention)
|
||
bare = _render_app_toml(DiscoveredApp(name="n", full_name="N", executable="C:\\n.exe"))
|
||
assert "url_schemes" not in bare
|
||
|
||
|
||
def test_backfill_adds_url_schemes_to_unchanged_toml(tmp_path):
|
||
# #694: an app persisted before the feature (unchanged exe -> not rewritten)
|
||
# must get url_schemes backfilled, without clobbering an existing list.
|
||
from winpodx.core.discovery import DiscoveredApp, _backfill_mime_types
|
||
|
||
toml = tmp_path / "app.toml"
|
||
toml.write_text('name = "edge"\nfull_name = "Edge"\nexecutable = "C:\\\\edge.exe"\n')
|
||
app = DiscoveredApp(
|
||
name="edge", full_name="Edge", executable="C:\\edge.exe", url_schemes=["http", "https"]
|
||
)
|
||
_backfill_mime_types(toml, app, mime_enabled=False) # mime off; schemes still land
|
||
assert 'url_schemes = ["http", "https"]' in toml.read_text()
|
||
|
||
# never clobber an existing list
|
||
toml.write_text('name = "edge"\nexecutable = "C:\\\\e.exe"\nurl_schemes = ["mailto"]\n')
|
||
_backfill_mime_types(toml, app, mime_enabled=False)
|
||
assert "mailto" in toml.read_text() and "http" not in toml.read_text()
|
||
assert _entry_to_discovered(_valid_entry(name=None)) is None
|
||
|
||
|
||
def test_entry_missing_path_rejected():
|
||
assert _entry_to_discovered(_valid_entry(path="")) is None
|
||
assert _entry_to_discovered(_valid_entry(path=None)) is None
|
||
|
||
|
||
def test_entry_invalid_source_normalized_to_win32():
|
||
app = _entry_to_discovered(_valid_entry(source="app_paths"))
|
||
assert app is not None
|
||
assert app.source == "win32"
|
||
|
||
|
||
def test_entry_valid_uwp_source_preserved():
|
||
app = _entry_to_discovered(
|
||
_valid_entry(
|
||
source="uwp",
|
||
launch_uri="Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||
path="C:\\Program Files\\WindowsApps\\Microsoft.WindowsCalculator_xxx",
|
||
)
|
||
)
|
||
assert app is not None
|
||
assert app.source == "uwp"
|
||
assert app.launch_uri
|
||
|
||
|
||
def test_entry_uwp_without_launch_uri_rejected():
|
||
app = _entry_to_discovered(
|
||
_valid_entry(source="uwp", launch_uri="", path="C:\\Program Files\\WindowsApps\\xxx")
|
||
)
|
||
assert app is None
|
||
|
||
|
||
# The guest PS script (scripts/windows/discover_apps.ps1) MUST emit a bare
|
||
# `PackageFamilyName!AppId` AUMID as launch_uri — never a `shell:AppsFolder\`
|
||
# URI. The host-side FreeRDP builder (src/winpodx/core/rdp.py) prepends the
|
||
# prefix itself, so a guest-side prefix produces
|
||
# `shell:AppsFolder\shell:AppsFolder\...`. This regex mirrors
|
||
# rdp._AUMID_RE so the discovery test suite can assert the contract without
|
||
# importing from another subpackage.
|
||
_BARE_AUMID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}![A-Za-z0-9._-]{1,64}$")
|
||
|
||
|
||
def test_uwp_launch_uri_is_bare_aumid_end_to_end():
|
||
"""Mock a guest JSON payload with a bare AUMID, round-trip through the
|
||
parser, and assert the persisted launch_uri still matches the bare form
|
||
(no `shell:AppsFolder\\` prefix leaks through)."""
|
||
payload = json.dumps(
|
||
[
|
||
_valid_entry(
|
||
name="Calculator",
|
||
source="uwp",
|
||
launch_uri="Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||
path="C:\\Program Files\\WindowsApps\\Microsoft.WindowsCalculator_xxx",
|
||
)
|
||
]
|
||
)
|
||
apps = _parse_discovery_output(payload)
|
||
assert len(apps) == 1
|
||
calc = apps[0]
|
||
assert calc.source == "uwp"
|
||
assert calc.launch_uri == "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
|
||
assert "shell:AppsFolder" not in calc.launch_uri
|
||
assert _BARE_AUMID_RE.fullmatch(calc.launch_uri) is not None
|
||
|
||
|
||
def test_entry_oversized_name_rejected():
|
||
app = _entry_to_discovered(_valid_entry(name="x" * 500))
|
||
assert app is None
|
||
|
||
|
||
def test_entry_oversized_path_rejected():
|
||
app = _entry_to_discovered(_valid_entry(path="C:\\" + "x" * 2000))
|
||
assert app is None
|
||
|
||
|
||
def test_entry_icon_base64_decoded():
|
||
icon = _valid_entry(icon_b64=base64.b64encode(_TINY_PNG).decode("ascii"))
|
||
app = _entry_to_discovered(icon)
|
||
assert app is not None
|
||
assert app.icon_bytes == _TINY_PNG
|
||
|
||
|
||
def test_entry_malformed_base64_yields_empty_icon():
|
||
app = _entry_to_discovered(_valid_entry(icon_b64="not base64!!!"))
|
||
assert app is not None
|
||
assert app.icon_bytes == b""
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"name,exe,src",
|
||
[
|
||
("Uninstall Adobe Reader", "C:\\Program Files\\Adobe\\unins000.exe", "win32"),
|
||
("Setup", "C:\\setup.exe", "win32"),
|
||
("Microsoft Visual C++ 2019 Redistributable", "C:\\vc_redist.x64.exe", "win32"),
|
||
("crashpad_handler", "C:\\Program Files\\Foo\\crashpad_handler.exe", "win32"),
|
||
("ApplicationFrameHost", "C:\\Windows\\System32\\ApplicationFrameHost.exe", "win32"),
|
||
("Microsoft.AAD.BrokerPlugin", "C:\\Program Files\\WindowsApps\\xxx", "uwp"),
|
||
("Report a problem", "C:\\Program Files\\App\\report.exe", "win32"),
|
||
("Send feedback", "C:\\Program Files\\App\\feedback.exe", "win32"),
|
||
("ReadMe", "C:\\Program Files\\App\\readme.exe", "win32"),
|
||
("Repair", "C:\\Program Files\\App\\repair.exe", "win32"),
|
||
(".NET Framework 4.8", "C:\\App\\dotnetfx.exe", "win32"),
|
||
],
|
||
)
|
||
def test_entry_junk_filter_drops_known_garbage(name, exe, src):
|
||
"""v0.2.0 junk filter: uninstallers, redistributables, unresolved UWP
|
||
plumbing must be dropped before they reach disk / GUI."""
|
||
entry = _valid_entry(
|
||
name=name,
|
||
path=exe,
|
||
source=src,
|
||
launch_uri="Foo_x!App" if src == "uwp" else "",
|
||
)
|
||
assert _entry_to_discovered(entry) is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"name,exe,src,uri",
|
||
[
|
||
("Microsoft Word", "C:\\Program Files\\Microsoft\\Word.exe", "win32", ""),
|
||
("Notepad++", "C:\\Program Files\\Notepad++\\notepad++.exe", "win32", ""),
|
||
("Calculator", "C:\\WindowsApps\\xxx", "uwp", "Microsoft.WindowsCalculator_xx!App"),
|
||
("Microsoft To Do", "C:\\WindowsApps\\xxx", "uwp", "Microsoft.Todos_xx!App"),
|
||
],
|
||
)
|
||
def test_entry_junk_filter_keeps_real_apps(name, exe, src, uri):
|
||
"""v0.2.0 junk filter must not nuke legitimate apps."""
|
||
entry = _valid_entry(name=name, path=exe, source=src, launch_uri=uri)
|
||
assert _entry_to_discovered(entry) is not None
|
||
|
||
|
||
def test_entry_junk_filter_bypassed_via_env(monkeypatch):
|
||
"""Setting WINPODX_DISCOVERY_INCLUDE_ALL=1 disables the filter for debugging."""
|
||
monkeypatch.setenv("WINPODX_DISCOVERY_INCLUDE_ALL", "1")
|
||
entry = _valid_entry(name="Uninstall Foo", path="C:\\Foo\\unins000.exe", source="win32")
|
||
assert _entry_to_discovered(entry) is not None
|
||
|
||
|
||
def test_entry_oversized_icon_dropped():
|
||
# >1 MiB decoded payload is silently dropped (entry still accepted).
|
||
big = _TINY_PNG + b"\x00" * (1_048_576 + 1)
|
||
entry = _valid_entry(icon_b64=base64.b64encode(big).decode("ascii"))
|
||
app = _entry_to_discovered(entry)
|
||
assert app is not None
|
||
assert app.icon_bytes == b""
|
||
|
||
|
||
# --- _parse_discovery_output -----------------------------------------------
|
||
|
||
|
||
def test_parse_empty_output_returns_empty_list():
|
||
assert _parse_discovery_output("") == []
|
||
assert _parse_discovery_output(" \n\n ") == []
|
||
|
||
|
||
def test_parse_happy_path():
|
||
payload = json.dumps([_valid_entry(name="App A"), _valid_entry(name="App B")])
|
||
apps = _parse_discovery_output(payload)
|
||
assert len(apps) == 2
|
||
assert apps[0].full_name == "App A"
|
||
assert apps[1].full_name == "App B"
|
||
|
||
|
||
def test_parse_malformed_json_raises():
|
||
with pytest.raises(DiscoveryError, match="Malformed"):
|
||
_parse_discovery_output("{ not json at all")
|
||
|
||
|
||
def test_parse_array_required_or_single_dict_coerced():
|
||
# A bare object becomes a single-element list per core's parser.
|
||
payload = json.dumps(_valid_entry(name="Solo"))
|
||
apps = _parse_discovery_output(payload)
|
||
assert len(apps) == 1
|
||
assert apps[0].full_name == "Solo"
|
||
|
||
|
||
def test_parse_scalar_json_raises():
|
||
# A scalar (string/int) cannot be coerced and must raise.
|
||
with pytest.raises(DiscoveryError, match="must be an array"):
|
||
_parse_discovery_output('"just a string"')
|
||
|
||
|
||
def test_parse_strips_utf8_bom():
|
||
payload = "" + json.dumps([_valid_entry()])
|
||
apps = _parse_discovery_output(payload)
|
||
assert len(apps) == 1
|
||
|
||
|
||
def test_parse_ignores_non_dict_entries():
|
||
payload = json.dumps([_valid_entry(), "garbage", 42, None, _valid_entry(name="B")])
|
||
apps = _parse_discovery_output(payload)
|
||
assert len(apps) == 2
|
||
|
||
|
||
def test_parse_truncated_flag_respected():
|
||
payload = json.dumps([_valid_entry(), {"_truncated": True}])
|
||
apps = _parse_discovery_output(payload)
|
||
# Truncated marker is consumed, not converted into an app.
|
||
assert len(apps) == 1
|
||
|
||
|
||
def test_parse_max_apps_cap():
|
||
# Feed >500 entries; parser should stop at MAX_APPS.
|
||
entries = [_valid_entry(name=f"App {i}") for i in range(600)]
|
||
apps = _parse_discovery_output(json.dumps(entries))
|
||
assert len(apps) == 500
|
||
|
||
|
||
# --- persist_discovered ----------------------------------------------------
|
||
|
||
|
||
def test_persist_writes_app_toml(tmp_path):
|
||
app = DiscoveredApp(
|
||
name="example-app",
|
||
full_name="Example App",
|
||
executable="C:\\Program Files\\Example\\example.exe",
|
||
)
|
||
written = persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
assert len(written) == 1
|
||
toml_path = written[0]
|
||
assert toml_path.exists()
|
||
content = toml_path.read_text(encoding="utf-8")
|
||
assert 'name = "example-app"' in content
|
||
assert 'full_name = "Example App"' in content
|
||
|
||
|
||
def test_persist_writes_png_icon(tmp_path):
|
||
# Valid PNG is required post-M1 (magic-only payloads are rejected).
|
||
png = _build_png(width=1, height=1, body_extra=b"\x00" * 4)
|
||
app = DiscoveredApp(
|
||
name="example-app",
|
||
full_name="Example",
|
||
executable="C:\\example.exe",
|
||
icon_bytes=png,
|
||
)
|
||
persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
icon = tmp_path / "example-app" / "icon.png"
|
||
assert icon.exists()
|
||
assert icon.read_bytes() == png
|
||
|
||
|
||
def test_persist_writes_svg_icon(tmp_path):
|
||
app = DiscoveredApp(
|
||
name="example-app",
|
||
full_name="Example",
|
||
executable="C:\\example.exe",
|
||
icon_bytes=_TINY_SVG,
|
||
)
|
||
persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
icon = tmp_path / "example-app" / "icon.svg"
|
||
assert icon.exists()
|
||
|
||
|
||
def test_persist_skips_unknown_icon_format(tmp_path):
|
||
app = DiscoveredApp(
|
||
name="example-app",
|
||
full_name="Example",
|
||
executable="C:\\example.exe",
|
||
icon_bytes=b"\xff\xd8\xff\xe0 garbage jpeg",
|
||
)
|
||
persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
app_dir = tmp_path / "example-app"
|
||
# app.toml is still written but no icon file in a recognized format.
|
||
assert (app_dir / "app.toml").exists()
|
||
assert not (app_dir / "icon.png").exists()
|
||
assert not (app_dir / "icon.svg").exists()
|
||
assert not (app_dir / "icon.jpg").exists()
|
||
|
||
|
||
def test_persist_deduplicates_by_slug(tmp_path):
|
||
# Two apps with the same slug: second should be silently skipped.
|
||
a = DiscoveredApp(name="example-app", full_name="A", executable="C:\\a.exe")
|
||
b = DiscoveredApp(name="example-app", full_name="B", executable="C:\\b.exe")
|
||
written = persist_discovered([a, b], target_dir=tmp_path, add_essentials=False)
|
||
assert len(written) == 1
|
||
# First-seen wins.
|
||
content = (tmp_path / "example-app" / "app.toml").read_text()
|
||
assert 'full_name = "A"' in content
|
||
|
||
|
||
def test_persist_replace_true_clears_old_entries(tmp_path):
|
||
# Prime the dir with stale content.
|
||
stale = tmp_path / "example-app"
|
||
stale.mkdir()
|
||
(stale / "stale.txt").write_text("leftover")
|
||
|
||
app = DiscoveredApp(name="example-app", full_name="Fresh", executable="C:\\e.exe")
|
||
persist_discovered([app], target_dir=tmp_path, replace=True, add_essentials=False)
|
||
assert not (stale / "stale.txt").exists()
|
||
assert (stale / "app.toml").exists()
|
||
|
||
|
||
def test_persist_replace_false_preserves_old_entries(tmp_path):
|
||
stale_dir = tmp_path / "example-app"
|
||
stale_dir.mkdir()
|
||
(stale_dir / "stale.txt").write_text("preserved")
|
||
|
||
app = DiscoveredApp(name="example-app", full_name="Fresh", executable="C:\\e.exe")
|
||
persist_discovered([app], target_dir=tmp_path, replace=False, add_essentials=False)
|
||
# app.toml still written (overwritten), but stale sibling stays.
|
||
assert (stale_dir / "stale.txt").exists()
|
||
assert (stale_dir / "app.toml").exists()
|
||
|
||
|
||
def test_persist_rejects_unsafe_slug(tmp_path):
|
||
# _SAFE_NAME_RE gatekeeper blocks anything not matching [a-zA-Z0-9_-].
|
||
bad = DiscoveredApp(name="has space", full_name="Bad", executable="C:\\b.exe")
|
||
written = persist_discovered([bad], target_dir=tmp_path, add_essentials=False)
|
||
assert written == []
|
||
assert not (tmp_path / "has space").exists()
|
||
|
||
|
||
# --- reverse-open shim filter ---------------------------------------------
|
||
|
||
|
||
def test_is_junk_entry_rejects_reverse_open_shim():
|
||
# Reverse-open shims live at C:\Users\Public\winpodx\reverse-open\bin\
|
||
# and must never be re-imported as Windows apps.
|
||
assert _is_junk_entry(
|
||
"Firefox",
|
||
"C:\\Users\\Public\\winpodx\\reverse-open\\bin\\winpodx-firefox.exe",
|
||
"win32",
|
||
)
|
||
# Case-insensitive: a guest scanner that lowercased or uppercased the path
|
||
# must still be filtered.
|
||
assert _is_junk_entry(
|
||
"Firefox",
|
||
"c:\\users\\public\\WinPodX\\Reverse-Open\\bin\\winpodx-firefox.exe",
|
||
"win32",
|
||
)
|
||
# Forward-slash variant (just in case a guest scanner emits POSIX paths).
|
||
assert _is_junk_entry(
|
||
"Firefox",
|
||
"C:/Users/Public/winpodx/reverse-open/bin/winpodx-firefox.exe",
|
||
"win32",
|
||
)
|
||
|
||
|
||
def test_is_junk_entry_accepts_real_windows_app():
|
||
assert not _is_junk_entry("Notepad", "C:\\Windows\\System32\\notepad.exe", "win32")
|
||
assert not _is_junk_entry("Firefox", "C:\\Program Files\\Mozilla Firefox\\firefox.exe", "win32")
|
||
|
||
|
||
def test_purge_reverse_open_entries_removes_polluted(tmp_path):
|
||
polluted = tmp_path / "firefox"
|
||
polluted.mkdir()
|
||
shim = "C:\\\\Users\\\\Public\\\\winpodx\\\\reverse-open\\\\bin\\\\winpodx-firefox.exe"
|
||
(polluted / "app.toml").write_text(
|
||
f'name = "firefox"\nfull_name = "Firefox"\nexecutable = "{shim}"\nsource = "win32"\n',
|
||
encoding="utf-8",
|
||
)
|
||
# And a real Windows app entry that should survive.
|
||
real = tmp_path / "notepad"
|
||
real.mkdir()
|
||
(real / "app.toml").write_text(
|
||
'name = "notepad"\nfull_name = "Notepad"\nexecutable = "C:\\\\Windows\\\\notepad.exe"\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
_purge_reverse_open_entries(tmp_path)
|
||
assert not polluted.exists()
|
||
assert real.exists()
|
||
|
||
|
||
def test_purge_reverse_open_entries_skips_when_root_missing(tmp_path):
|
||
# No-op on missing root; must not raise.
|
||
_purge_reverse_open_entries(tmp_path / "does-not-exist")
|
||
|
||
|
||
def test_purge_reverse_open_entries_tolerates_broken_toml(tmp_path):
|
||
broken = tmp_path / "broken"
|
||
broken.mkdir()
|
||
(broken / "app.toml").write_text("garbage = [unterminated", encoding="utf-8")
|
||
polluted = tmp_path / "polluted"
|
||
polluted.mkdir()
|
||
(polluted / "app.toml").write_text(
|
||
'executable = "C:\\\\Users\\\\Public\\\\winpodx\\\\reverse-open\\\\bin\\\\winpodx-x.exe"\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
_purge_reverse_open_entries(tmp_path)
|
||
# Broken stays (we cannot prove it is reverse-open).
|
||
assert broken.exists()
|
||
# Polluted is gone.
|
||
assert not polluted.exists()
|
||
|
||
|
||
def test_persist_discovered_self_heals_reverse_open(tmp_path):
|
||
# Simulate prior pollution: discovered/ contains a stale reverse-open entry.
|
||
stale = tmp_path / "firefox"
|
||
stale.mkdir()
|
||
shim = "C:\\\\Users\\\\Public\\\\winpodx\\\\reverse-open\\\\bin\\\\winpodx-firefox.exe"
|
||
(stale / "app.toml").write_text(
|
||
f'name = "firefox"\nexecutable = "{shim}"\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# A new discovery run with a real Windows app — purge runs first, then write.
|
||
real = DiscoveredApp(name="notepad", full_name="Notepad", executable="C:\\Windows\\notepad.exe")
|
||
persist_discovered([real], target_dir=tmp_path, add_essentials=False)
|
||
assert not stale.exists() # purged
|
||
assert (tmp_path / "notepad" / "app.toml").exists()
|
||
|
||
|
||
# --- _safe_rmtree ----------------------------------------------------------
|
||
|
||
|
||
def test_safe_rmtree_removes_subpath(tmp_path):
|
||
inner = tmp_path / "inner"
|
||
inner.mkdir()
|
||
(inner / "file.txt").write_text("x")
|
||
_safe_rmtree(inner, tmp_path)
|
||
assert not inner.exists()
|
||
|
||
|
||
def test_safe_rmtree_refuses_escape(tmp_path, caplog):
|
||
# Construct a path that resolves outside the target root.
|
||
other = tmp_path.parent / f"winpodx-test-escape-{tmp_path.name}"
|
||
other.mkdir()
|
||
(other / "keep.txt").write_text("should survive")
|
||
try:
|
||
_safe_rmtree(other, tmp_path)
|
||
assert other.exists()
|
||
assert (other / "keep.txt").exists()
|
||
finally:
|
||
# Cleanup regardless of test outcome.
|
||
for f in other.iterdir():
|
||
f.unlink()
|
||
other.rmdir()
|
||
|
||
|
||
def test_safe_rmtree_handles_symlink(tmp_path):
|
||
target = tmp_path / "target"
|
||
target.mkdir()
|
||
(target / "file.txt").write_text("x")
|
||
link = tmp_path / "link"
|
||
try:
|
||
link.symlink_to(target)
|
||
except (OSError, NotImplementedError):
|
||
pytest.skip("Symlinks unsupported on this platform")
|
||
_safe_rmtree(link, tmp_path)
|
||
# Symlink is removed without following into the target.
|
||
assert not link.exists()
|
||
assert target.exists()
|
||
assert (target / "file.txt").exists()
|
||
|
||
|
||
# --- discover_apps end-to-end ----------------------------------------------
|
||
|
||
|
||
def _make_cfg(backend: str = "podman") -> Config:
|
||
cfg = Config()
|
||
cfg.pod.backend = backend
|
||
cfg.pod.container_name = "winpodx-test"
|
||
return cfg
|
||
|
||
|
||
def test_discover_rejects_libvirt_backend():
|
||
cfg = _make_cfg(backend="libvirt")
|
||
with pytest.raises(DiscoveryError, match="container backend"):
|
||
discover_apps(cfg)
|
||
|
||
|
||
def test_discover_rejects_manual_backend():
|
||
cfg = _make_cfg(backend="manual")
|
||
with pytest.raises(DiscoveryError, match="container backend"):
|
||
discover_apps(cfg)
|
||
|
||
|
||
def test_discover_requires_runtime_on_path():
|
||
cfg = _make_cfg(backend="podman")
|
||
with patch("winpodx.core.discovery.shutil.which", return_value=None):
|
||
with pytest.raises(DiscoveryError, match="not found on PATH"):
|
||
discover_apps(cfg)
|
||
|
||
|
||
def test_discover_requires_script_file():
|
||
cfg = _make_cfg(backend="podman")
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch(
|
||
"winpodx.core.discovery._ps_script_path",
|
||
return_value=Path("/nonexistent/discover_apps.ps1"),
|
||
),
|
||
):
|
||
with pytest.raises(DiscoveryError, match="Discovery script not found"):
|
||
discover_apps(cfg)
|
||
|
||
|
||
def _force_freerdp_path(monkeypatch):
|
||
"""Make discover_apps fall through to the FreeRDP run_in_windows path
|
||
instead of the agent transport.
|
||
|
||
Tests stub run_in_windows; if dispatch() resolves to an AgentTransport
|
||
(which happens on dev boxes that have a real agent listening on
|
||
127.0.0.1:8765), our code never reaches run_in_windows and the stub
|
||
is bypassed. Patch dispatch to raise so the discover_apps wrapper
|
||
catches it and continues to the FreeRDP fallback.
|
||
"""
|
||
|
||
def boom(_cfg, **_kw):
|
||
raise RuntimeError("agent transport disabled for tests")
|
||
|
||
monkeypatch.setattr("winpodx.core.transport.dispatch", boom)
|
||
|
||
|
||
def _stub_run_in_windows(
|
||
monkeypatch,
|
||
*,
|
||
rc: int = 0,
|
||
stdout: str = "[]",
|
||
stderr: str = "",
|
||
raise_exc: Exception | None = None,
|
||
):
|
||
"""v0.1.9.5: discover_apps now goes through windows_exec.run_in_windows.
|
||
Tests stub the new entry point and capture the (description, payload) pair.
|
||
|
||
Implicitly forces the FreeRDP path so a live agent on the dev box
|
||
doesn't bypass the stub via the new transport.dispatch route.
|
||
"""
|
||
_force_freerdp_path(monkeypatch)
|
||
from winpodx.core.windows_exec import WindowsExecResult
|
||
|
||
captured: dict[str, str] = {}
|
||
|
||
def fake(cfg_inner, payload, *, timeout=60, description="windows-exec", **kw):
|
||
captured["description"] = description
|
||
captured["payload"] = payload
|
||
captured["timeout"] = timeout
|
||
captured["progress_callback"] = kw.get("progress_callback")
|
||
if raise_exc is not None:
|
||
raise raise_exc
|
||
return WindowsExecResult(rc=rc, stdout=stdout, stderr=stderr)
|
||
|
||
monkeypatch.setattr("winpodx.core.windows_exec.run_in_windows", fake)
|
||
return captured
|
||
|
||
|
||
def test_discover_surfaces_pod_not_running(tmp_path, monkeypatch):
|
||
"""v0.1.9.5: WindowsExecError with auth/no-result-file → pod_not_running kind."""
|
||
from winpodx.core.windows_exec import WindowsExecError
|
||
|
||
cfg = _make_cfg(backend="podman")
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text("# stub")
|
||
|
||
_stub_run_in_windows(
|
||
monkeypatch,
|
||
raise_exc=WindowsExecError(
|
||
"No result file written (FreeRDP rc=1). stderr tail: 'auth failure'"
|
||
),
|
||
)
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
with pytest.raises(DiscoveryError, match="channel failure") as excinfo:
|
||
discover_apps(cfg)
|
||
assert excinfo.value.kind == "pod_not_running"
|
||
|
||
|
||
def test_discover_happy_path_roundtrip(tmp_path, monkeypatch):
|
||
cfg = _make_cfg(backend="podman")
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text("# stub")
|
||
|
||
payload = json.dumps([_valid_entry(name="Calculator")])
|
||
captured = _stub_run_in_windows(monkeypatch, rc=0, stdout=payload)
|
||
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
apps = discover_apps(cfg)
|
||
|
||
assert len(apps) == 1
|
||
assert apps[0].full_name == "Calculator"
|
||
assert captured["description"] == "discover-apps"
|
||
# Payload must be the script contents (windows_exec wraps it).
|
||
assert "# stub" in captured["payload"]
|
||
|
||
|
||
# --- #581: full_app_scan host-side script patch ----------------------------
|
||
|
||
_FULLSCAN_SENTINEL_SCRIPT = "param([switch]$FullScan)\n$WinpodxFullScan = $false\n# body"
|
||
|
||
|
||
def test_full_app_scan_off_leaves_startmenu_default(tmp_path, monkeypatch):
|
||
"""Default (full_app_scan False): the script body reaches the guest with
|
||
its Start-Menu-only sentinel untouched."""
|
||
cfg = _make_cfg(backend="podman")
|
||
cfg.desktop.full_app_scan = False
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text(_FULLSCAN_SENTINEL_SCRIPT)
|
||
|
||
captured = _stub_run_in_windows(monkeypatch, rc=0, stdout="[]")
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
discover_apps(cfg)
|
||
|
||
assert "$WinpodxFullScan = $false" in captured["payload"]
|
||
assert "$WinpodxFullScan = $true" not in captured["payload"]
|
||
|
||
|
||
def test_full_app_scan_on_patches_sentinel_to_true(tmp_path, monkeypatch):
|
||
"""Opt-in (full_app_scan True): the host flips the sentinel so the guest
|
||
runs the legacy 5-source scan."""
|
||
cfg = _make_cfg(backend="podman")
|
||
cfg.desktop.full_app_scan = True
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text(_FULLSCAN_SENTINEL_SCRIPT)
|
||
|
||
captured = _stub_run_in_windows(monkeypatch, rc=0, stdout="[]")
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
discover_apps(cfg)
|
||
|
||
assert "$WinpodxFullScan = $true" in captured["payload"]
|
||
# Only the assignment flips; the param switch default stays intact.
|
||
assert "$WinpodxFullScan = $false" not in captured["payload"]
|
||
|
||
|
||
def test_discover_nonzero_exit_raises(tmp_path, monkeypatch):
|
||
cfg = _make_cfg(backend="podman")
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text("# stub")
|
||
|
||
_stub_run_in_windows(monkeypatch, rc=42, stderr="Get-AppxPackage failed")
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
with pytest.raises(DiscoveryError, match="rc=42") as excinfo:
|
||
discover_apps(cfg)
|
||
assert excinfo.value.kind == "script_failed"
|
||
|
||
|
||
def test_discover_uses_windows_exec_channel(tmp_path, monkeypatch):
|
||
"""v0.1.9.5: the discover_apps.ps1 body is forwarded to windows_exec, NOT
|
||
sent through podman exec (which only reaches the Linux container)."""
|
||
cfg = _make_cfg(backend="podman")
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text("# the actual ps1 body that should reach Windows")
|
||
|
||
captured = _stub_run_in_windows(monkeypatch, rc=0, stdout="[]")
|
||
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
discover_apps(cfg)
|
||
|
||
assert "the actual ps1 body that should reach Windows" in captured["payload"]
|
||
assert captured["description"] == "discover-apps"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# core-team additions (v0.1.8 blockers — I1 / I2 / M1 / L1)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_discovery_error_kind_preserved():
|
||
"""I1: DiscoveryError must round-trip the `kind` keyword."""
|
||
err = DiscoveryError("boom", kind="script_failed")
|
||
assert str(err) == "boom"
|
||
assert err.kind == "script_failed"
|
||
|
||
# Default must also hold so legacy zero-arg construction still works.
|
||
bare = DiscoveryError()
|
||
assert bare.kind == ""
|
||
|
||
# And `kind` must be reachable from a raise-and-catch flow.
|
||
with pytest.raises(DiscoveryError) as excinfo:
|
||
raise DiscoveryError("x", kind="timeout")
|
||
assert excinfo.value.kind == "timeout"
|
||
|
||
|
||
def test_discovery_error_kinds_at_all_raise_sites():
|
||
"""I1 coverage: every raise site in discovery.py emits a canonical kind."""
|
||
# unsupported_backend
|
||
cfg = _make_cfg(backend="libvirt")
|
||
with pytest.raises(DiscoveryError) as e1:
|
||
discover_apps(cfg)
|
||
assert e1.value.kind == "unsupported_backend"
|
||
|
||
# pod_not_running (runtime missing on PATH)
|
||
cfg = _make_cfg(backend="podman")
|
||
with patch("winpodx.core.discovery.shutil.which", return_value=None):
|
||
with pytest.raises(DiscoveryError) as e2:
|
||
discover_apps(cfg)
|
||
assert e2.value.kind == "pod_not_running"
|
||
|
||
# script_missing
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch(
|
||
"winpodx.core.discovery._ps_script_path",
|
||
return_value=Path("/nonexistent/discover_apps.ps1"),
|
||
),
|
||
):
|
||
with pytest.raises(DiscoveryError) as e3:
|
||
discover_apps(cfg)
|
||
assert e3.value.kind == "script_missing"
|
||
|
||
# bad_json
|
||
with pytest.raises(DiscoveryError) as e4:
|
||
_parse_discovery_output("{ not json")
|
||
assert e4.value.kind == "bad_json"
|
||
|
||
|
||
def test_discovered_app_has_slug_and_icon_path_after_persist(tmp_path):
|
||
"""I2: slug + icon_path must be populated post persist_discovered."""
|
||
png = _build_png(width=2, height=2, body_extra=b"\x00" * 4)
|
||
app = DiscoveredApp(
|
||
name="example-app",
|
||
full_name="Example App",
|
||
executable="C:\\Example\\example.exe",
|
||
icon_bytes=png,
|
||
)
|
||
# Before persist: empty contract fields.
|
||
assert app.slug == ""
|
||
assert app.icon_path == ""
|
||
|
||
persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
|
||
assert app.slug == "example-app"
|
||
expected_icon = (tmp_path / "example-app" / "icon.png").resolve()
|
||
assert app.icon_path == str(expected_icon)
|
||
assert Path(app.icon_path).is_file()
|
||
|
||
|
||
def test_discovered_app_icon_path_empty_when_no_icon(tmp_path):
|
||
"""I2: slug still stamped even when no icon was supplied."""
|
||
app = DiscoveredApp(name="no-icon", full_name="NoIcon", executable="C:\\x.exe")
|
||
persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
assert app.slug == "no-icon"
|
||
assert app.icon_path == ""
|
||
|
||
|
||
def test_validate_png_rejects_crafted_magic_only_payload():
|
||
"""M1: magic bytes + garbage must not be trusted as a PNG."""
|
||
crafted = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||
assert _validate_png_bytes(crafted) is False
|
||
|
||
|
||
def test_validate_png_rejects_empty_bytes():
|
||
assert _validate_png_bytes(b"") is False
|
||
|
||
|
||
def test_validate_png_rejects_wrong_magic():
|
||
assert _validate_png_bytes(b"GIF89a" + b"\x00" * 100) is False
|
||
|
||
|
||
def test_validate_png_stdlib_rejects_crc_corruption():
|
||
"""M1: a single-bit flip in IHDR CRC must be caught by the stdlib walker."""
|
||
from winpodx.core.discovery import _validate_png_stdlib
|
||
|
||
png = _build_png(corrupt_crc=True)
|
||
assert _validate_png_stdlib(png) is False
|
||
|
||
|
||
def test_validate_png_stdlib_rejects_oversized_dimensions():
|
||
"""M1: a 2048x2048 PNG must be rejected even with valid chunks."""
|
||
from winpodx.core.discovery import _validate_png_stdlib
|
||
|
||
png = _build_png(width=2048, height=2048)
|
||
assert _validate_png_stdlib(png) is False
|
||
|
||
|
||
def test_validate_png_accepts_real_png():
|
||
"""M1: a structurally valid PNG passes validation."""
|
||
png = _build_png(width=1, height=1, body_extra=b"\x00" * 8)
|
||
assert _validate_png_bytes(png) is True
|
||
|
||
|
||
def test_persist_rejects_malformed_png_but_persists_entry(tmp_path):
|
||
"""M1 integration: malformed PNG must not abort the run; entry still written."""
|
||
crafted = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||
app = DiscoveredApp(
|
||
name="badpng-app",
|
||
full_name="BadPng",
|
||
executable="C:\\b.exe",
|
||
icon_bytes=crafted,
|
||
)
|
||
written = persist_discovered([app], target_dir=tmp_path, add_essentials=False)
|
||
assert len(written) == 1 # entry persists
|
||
app_dir = tmp_path / "badpng-app"
|
||
assert (app_dir / "app.toml").exists()
|
||
assert not (app_dir / "icon.png").exists() # icon rejected
|
||
assert app.icon_path == ""
|
||
|
||
|
||
# test_discovery_stdout_cap_triggers_truncated_error: removed in v0.1.9.5.
|
||
# discover_apps used to drive subprocess.Popen via _run_bounded with a 64 MiB
|
||
# stdout cap; the migration to windows_exec.run_in_windows replaces that
|
||
# transport entirely (the FreeRDP RemoteApp wrapper writes a JSON file via
|
||
# tsclient redirection, no streaming subprocess). The cap is no longer the
|
||
# right invariant to test — the analogous L1-style guard would be a result-
|
||
# file size limit, which can be added if real-world JSON gets unmanageable.
|
||
|
||
|
||
def test_discover_surfaces_timeout(tmp_path, monkeypatch):
|
||
"""v0.1.9.5: timeouts now surface from windows_exec, not _run_bounded."""
|
||
from winpodx.core.windows_exec import WindowsExecError
|
||
|
||
cfg = _make_cfg(backend="podman")
|
||
script = tmp_path / "discover_apps.ps1"
|
||
script.write_text("# stub")
|
||
|
||
_stub_run_in_windows(
|
||
monkeypatch,
|
||
raise_exc=WindowsExecError("FreeRDP timed out after 1s"),
|
||
)
|
||
with (
|
||
patch("winpodx.core.discovery.shutil.which", return_value="/usr/bin/podman"),
|
||
patch("winpodx.core.discovery._ps_script_path", return_value=script),
|
||
):
|
||
with pytest.raises(DiscoveryError, match="channel failure") as excinfo:
|
||
discover_apps(cfg, timeout=1)
|
||
# Timeout is a channel-level failure; classifies as script_failed
|
||
# since the script never got a chance to actually fail.
|
||
assert excinfo.value.kind == "script_failed"
|
||
|
||
|
||
# --- Hybrid filter: essentials allowlist + noise denylist ------------------
|
||
# (P0 work for v0.3.x — file explorer always shows, system shims hidden,
|
||
# user override survives rediscovery.)
|
||
|
||
|
||
def test_essentials_synthesized_when_scan_misses_them(tmp_path):
|
||
"""File Explorer / Calculator / Settings appear even when the guest
|
||
scan returns zero apps."""
|
||
written = persist_discovered([], target_dir=tmp_path, add_essentials=True)
|
||
slugs = {p.parent.name for p in written}
|
||
assert "file-explorer" in slugs
|
||
assert "calculator" in slugs
|
||
assert "settings" in slugs
|
||
|
||
|
||
def test_essentials_marked_essential_in_toml(tmp_path):
|
||
"""The synthesized stub gets ``essential = true`` so the GUI flags it."""
|
||
persist_discovered([], target_dir=tmp_path, add_essentials=True)
|
||
toml_text = (tmp_path / "file-explorer" / "app.toml").read_text()
|
||
assert "essential = true" in toml_text
|
||
|
||
|
||
def test_existing_app_promoted_to_essential_when_slug_matches(tmp_path):
|
||
"""If the scan already found 'file-explorer', we don't duplicate;
|
||
the existing entry is promoted with essential=True."""
|
||
scanned = DiscoveredApp(
|
||
name="file-explorer",
|
||
full_name="File Explorer",
|
||
executable="C:\\Windows\\explorer.exe",
|
||
)
|
||
written = persist_discovered([scanned], target_dir=tmp_path, add_essentials=True)
|
||
# No duplicate file-explorer entries.
|
||
explorer_paths = [p for p in written if p.parent.name == "file-explorer"]
|
||
assert len(explorer_paths) == 1
|
||
toml_text = explorer_paths[0].read_text()
|
||
assert "essential = true" in toml_text
|
||
|
||
|
||
def test_noise_pattern_auto_hides_shim(tmp_path):
|
||
"""A slug matching NOISE_PATTERNS gets ``hidden = true`` stamped."""
|
||
shim = DiscoveredApp(
|
||
name="licensemanagershellext",
|
||
full_name="LicenseManagerShellExt",
|
||
executable="C:\\Windows\\System32\\shim.exe",
|
||
)
|
||
persist_discovered([shim], target_dir=tmp_path, add_essentials=False)
|
||
toml_text = (tmp_path / "licensemanagershellext" / "app.toml").read_text()
|
||
assert "hidden = true" in toml_text
|
||
|
||
|
||
def test_user_override_survives_rediscovery(tmp_path):
|
||
"""If a user manually unhid an app (hidden = false in app.toml),
|
||
the next persist must keep it shown even when the noise pattern
|
||
would auto-hide it."""
|
||
# Plant the user's override.
|
||
app_dir = tmp_path / "microsoft-store-server"
|
||
app_dir.mkdir()
|
||
(app_dir / "app.toml").write_text(
|
||
'name = "microsoft-store-server"\n'
|
||
'full_name = "Microsoft Store Server"\n'
|
||
'executable = "C:\\\\x.exe"\n'
|
||
"hidden = false\n",
|
||
encoding="utf-8",
|
||
)
|
||
# Re-run persist with the same slug — the noise pattern matches but
|
||
# the user override should win.
|
||
rerun = DiscoveredApp(
|
||
name="microsoft-store-server",
|
||
full_name="Microsoft Store Server",
|
||
executable="C:\\x.exe",
|
||
)
|
||
persist_discovered([rerun], target_dir=tmp_path, add_essentials=False)
|
||
toml_text = (tmp_path / "microsoft-store-server" / "app.toml").read_text()
|
||
assert "hidden = true" not in toml_text
|
||
|
||
|
||
def test_noise_pattern_yields_to_essential(tmp_path):
|
||
"""Hypothetical: even if a noise pattern accidentally matched an
|
||
essential slug, the essential allowlist must take precedence so a
|
||
bad pattern can't hide File Explorer."""
|
||
persist_discovered([], target_dir=tmp_path, add_essentials=True)
|
||
toml_text = (tmp_path / "file-explorer" / "app.toml").read_text()
|
||
assert "hidden = true" not in toml_text
|
||
|
||
|
||
# --- #581 Goal 2: start_menu_folder sanitize + thread-through -------------
|
||
|
||
|
||
def test_sanitize_start_menu_folder_normal():
|
||
assert _sanitize_start_menu_folder("Microsoft Office\\Tools") == "Microsoft Office/Tools"
|
||
|
||
|
||
def test_sanitize_start_menu_folder_empty_and_nonstr():
|
||
assert _sanitize_start_menu_folder("") == ""
|
||
assert _sanitize_start_menu_folder(" ") == ""
|
||
assert _sanitize_start_menu_folder(None) == ""
|
||
assert _sanitize_start_menu_folder(123) == ""
|
||
|
||
|
||
def test_sanitize_start_menu_folder_drops_traversal_and_drive():
|
||
# ".." / "." components and drive-letter / ADS components are removed.
|
||
assert _sanitize_start_menu_folder("..\\..\\Evil") == "Evil"
|
||
assert _sanitize_start_menu_folder("C:\\Windows\\System32") == "Windows/System32"
|
||
assert _sanitize_start_menu_folder(".\\Foo\\.\\Bar") == "Foo/Bar"
|
||
|
||
|
||
def test_sanitize_start_menu_folder_caps_depth():
|
||
assert _sanitize_start_menu_folder("a/b/c/d/e/f") == "a/b/c/d"
|
||
|
||
|
||
def test_sanitize_start_menu_folder_strips_control_chars():
|
||
assert _sanitize_start_menu_folder("Of\nfice\t/To\rols") == "Office/Tools"
|
||
|
||
|
||
def test_entry_to_discovered_carries_folder():
|
||
app = _entry_to_discovered(
|
||
_valid_entry(name="Word", start_menu_folder="Microsoft Office\\Tools")
|
||
)
|
||
assert app is not None
|
||
assert app.start_menu_folder == "Microsoft Office/Tools"
|
||
|
||
|
||
def test_entry_to_discovered_default_folder_empty():
|
||
app = _entry_to_discovered(_valid_entry(name="Word"))
|
||
assert app is not None
|
||
assert app.start_menu_folder == ""
|
||
|
||
|
||
def test_render_app_toml_emits_folder_when_set():
|
||
app = DiscoveredApp(
|
||
name="word",
|
||
full_name="Word",
|
||
executable="C:\\w.exe",
|
||
start_menu_folder="Microsoft Office/Tools",
|
||
)
|
||
toml_text = _render_app_toml(app)
|
||
assert 'start_menu_folder = "Microsoft Office/Tools"' in toml_text
|
||
|
||
|
||
def test_render_app_toml_omits_folder_when_empty():
|
||
app = DiscoveredApp(name="word", full_name="Word", executable="C:\\w.exe")
|
||
assert "start_menu_folder" not in _render_app_toml(app)
|
||
|
||
|
||
def test_folder_round_trips_through_persist_and_load(tmp_path):
|
||
from winpodx.core.app import load_app
|
||
|
||
persist_discovered(
|
||
[
|
||
DiscoveredApp(
|
||
name="word",
|
||
full_name="Word",
|
||
executable="C:\\w.exe",
|
||
start_menu_folder="Microsoft Office/Tools",
|
||
)
|
||
],
|
||
target_dir=tmp_path,
|
||
add_essentials=False,
|
||
)
|
||
loaded = load_app(tmp_path / "word")
|
||
assert loaded is not None
|
||
assert loaded.start_menu_folder == "Microsoft Office/Tools"
|
||
|
||
|
||
# --- #581: refresh migration — orphan prune + user-dir preservation -------
|
||
|
||
|
||
def test_persist_prunes_orphaned_discovered_entries(tmp_path):
|
||
# The migration: a later, smaller scan removes discovered dirs no longer
|
||
# present (the legacy full->Start-Menu shrink case).
|
||
big = [
|
||
DiscoveredApp(name=n, full_name=n, executable=f"C:\\{n}.exe")
|
||
for n in ("alpha", "beta", "gamma")
|
||
]
|
||
persist_discovered(big, target_dir=tmp_path, add_essentials=False)
|
||
assert {p.name for p in tmp_path.iterdir()} == {"alpha", "beta", "gamma"}
|
||
|
||
persist_discovered(
|
||
[DiscoveredApp(name="alpha", full_name="alpha", executable="C:\\alpha.exe")],
|
||
target_dir=tmp_path,
|
||
add_essentials=False,
|
||
)
|
||
assert {p.name for p in tmp_path.iterdir()} == {"alpha"}
|
||
|
||
|
||
def test_persist_empty_scan_does_not_prune(tmp_path):
|
||
# A failed/empty discovery must NOT wipe the existing menu down to nothing.
|
||
persist_discovered(
|
||
[DiscoveredApp(name="keep", full_name="keep", executable="C:\\k.exe")],
|
||
target_dir=tmp_path,
|
||
add_essentials=False,
|
||
)
|
||
persist_discovered([], target_dir=tmp_path, add_essentials=False)
|
||
assert (tmp_path / "keep").exists()
|
||
|
||
|
||
def test_persist_does_not_touch_user_apps_dir(tmp_path, monkeypatch):
|
||
# A manually-added app (user_apps_dir) survives a discovery refresh that does
|
||
# not include it -- the prune is scoped to the discovered dir only.
|
||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||
from winpodx.core.app import discovered_apps_dir, user_apps_dir
|
||
|
||
user_app = user_apps_dir() / "myportable"
|
||
user_app.mkdir(parents=True)
|
||
(user_app / "app.toml").write_text(
|
||
'name = "myportable"\nfull_name = "My Portable"\nexecutable = "C:\\\\p.exe"\n',
|
||
encoding="utf-8",
|
||
)
|
||
|
||
# Discover a totally different set into the (separate) discovered dir.
|
||
persist_discovered(
|
||
[DiscoveredApp(name="discovered1", full_name="D1", executable="C:\\d.exe")],
|
||
target_dir=discovered_apps_dir(),
|
||
add_essentials=False,
|
||
)
|
||
assert user_app.exists()
|
||
assert (user_app / "app.toml").exists()
|