Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

1341 lines
48 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()