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

291 lines
11 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for host<->guest device passthrough core (#286, core/devices.py)."""
from __future__ import annotations
import types
import pytest
from winpodx.core import devices as D
# --- parse / format round-trip ------------------------------------------
def test_parse_entry_usb():
dc = D.parse_entry("usb|1234:5678|Security Dongle")
assert dc is not None
assert dc.dtype == "usb" and dc.did == "1234:5678" and dc.label == "Security Dongle"
assert dc.key == "usb:1234:5678"
assert dc.to_entry() == "usb|1234:5678|Security Dongle"
def test_parse_entry_pci_with_and_without_domain():
assert D.parse_entry("pci|0000:01:00.0|Card").did == "0000:01:00.0"
assert D.parse_entry("pci|01:00.0|Card").did == "01:00.0"
def test_parse_entry_rejects_malformed():
assert D.parse_entry("usb|zzzz:5678|x") is None # bad hex
assert D.parse_entry("usb|1234|x") is None # missing pid
assert D.parse_entry("bogus|1234:5678|x") is None # bad type
assert D.parse_entry("usb|1234:5678") is None # missing label field
assert D.parse_entry("pci|notaddr|x") is None
def test_parse_entries_drops_bad():
out = D.parse_entries(["usb|1234:5678|A", "bad|x|y", "pci|01:00.0|B"])
assert [d.key for d in out] == ["usb:1234:5678", "pci:01:00.0"]
# --- lsusb / lspci parsing ----------------------------------------------
def test_parse_lsusb_skips_root_hub_and_parses_fields():
out = D.parse_lsusb(
"Bus 003 Device 004: ID 1234:5678 ACME Security Dongle\n"
"Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub\n"
)
assert len(out) == 1
d = out[0]
assert d.dtype == "usb" and d.did == "1234:5678"
assert d.bus == "003" and d.addr == "004"
assert "ACME" in d.label
def test_parse_lsusb_dedups_same_vidpid():
out = D.parse_lsusb(
"Bus 003 Device 004: ID 1234:5678 Dongle A\nBus 003 Device 007: ID 1234:5678 Dongle A\n"
)
assert len(out) == 1
def test_parse_lspci_class_and_iommu():
out = D.parse_lspci(
'0000:01:00.0 "0300" "NVIDIA" "GA104"\n0000:00:1f.2 "0106" "Intel" "SATA"\n',
iommu_lookup=lambda a: {"0000:01:00.0": "15", "0000:00:1f.2": "7"}.get(a),
)
assert out[0].pci_class == "03" and out[0].iommu_group == "15"
assert out[1].pci_class == "01" and out[1].iommu_group == "7"
# --- safety classifier ---------------------------------------------------
def test_classify_usb_is_safe():
s = D.classify_safety(D.HostDevice(dtype="usb", did="1234:5678"))
assert s.safe and s.reasons == []
@pytest.mark.parametrize(
"cls,needle",
[("03", "GPU"), ("01", "boot/root disk"), ("02", "uplink"), ("0c", "vfio-pci")],
)
def test_classify_pci_is_risky_with_reason(cls, needle):
s = D.classify_safety(D.HostDevice(dtype="pci", did="01:00.0", pci_class=cls))
assert not s.safe
assert any(needle in r for r in s.reasons)
def test_classify_pci_notes_iommu_group():
s = D.classify_safety(D.HostDevice(dtype="pci", did="01:00.0", pci_class="04", iommu_group="9"))
assert any("IOMMU group 9" in r for r in s.reasons)
# --- qemu arg builders ---------------------------------------------------
def test_qemu_device_args():
args = D.qemu_device_args(
[D.DeviceConfig("usb", "1234:5678", "x"), D.DeviceConfig("pci", "0000:01:00.0", "y")]
)
assert "usb-host,vendorid=0x1234,productid=0x5678" in args
assert "vfio-pci,host=0000:01:00.0" in args
def test_host_device_nodes():
assert D.host_device_nodes([D.DeviceConfig("usb", "1234:5678")]) == ["/dev/bus/usb"]
assert D.host_device_nodes([D.DeviceConfig("pci", "01:00.0")]) == ["/dev/vfio/vfio"]
assert D.host_device_nodes([]) == []
def test_usb_qom_id_stable():
dc = D.DeviceConfig("usb", "1234:5678")
assert D.usb_qom_id(dc) == "winpodx-usb-12345678"
# --- live USB via dockur's QEMU monitor (HMP over `<backend> exec`) ------
def _fake_run(reply: str = "", *, rc: int = 0, stderr: str = "", captured: list | None = None):
"""Return a fake subprocess.run that records argv + yields a scripted reply."""
def run(cmd, **kwargs):
if captured is not None:
captured.append(cmd)
return types.SimpleNamespace(returncode=rc, stdout=reply, stderr=stderr)
return run
def test_live_attach_delegates_to_usbredir(monkeypatch):
# live_attach no longer uses `device_add usb-host` (QEMU's in-container
# libusb is frozen — see core/usbredir). It delegates to the usbredir path.
from winpodx.core import usbredir
calls: list = []
monkeypatch.setattr(usbredir, "attach", lambda be, c, dev: calls.append((be, c, dev.did)))
D.live_attach("podman", "winpodx-windows", D.DeviceConfig("usb", "1234:5678", "Dongle"))
assert calls == [("podman", "winpodx-windows", "1234:5678")]
def test_live_attach_propagates_usbredir_error(monkeypatch):
from winpodx.core import usbredir
def boom(be, c, dev):
raise D.HmpError("usbredirect not found")
monkeypatch.setattr(usbredir, "attach", boom)
with pytest.raises(D.HmpError, match="usbredirect"):
D.live_attach("podman", "winpodx-windows", D.DeviceConfig("usb", "1234:5678"))
def test_live_detach_delegates_to_usbredir(monkeypatch):
from winpodx.core import usbredir
calls: list = []
monkeypatch.setattr(usbredir, "detach", lambda be, c, dev: calls.append((be, c, dev.did)))
D.live_detach("podman", "winpodx-windows", D.DeviceConfig("usb", "1234:5678"))
assert calls == [("podman", "winpodx-windows", "1234:5678")]
def test_live_attach_rejects_pci():
with pytest.raises(D.HmpError, match="only supports USB"):
D.live_attach("podman", "winpodx-windows", D.DeviceConfig("pci", "01:00.0"))
def test_hmp_command_unreachable_raises(monkeypatch):
monkeypatch.setattr(D.subprocess, "run", _fake_run("", rc=7, stderr="connection refused"))
with pytest.raises(D.HmpError, match="unreachable"):
D.hmp_command("podman", "winpodx-windows", "info version")
# --- compose integration -------------------------------------------------
def test_compose_default_wires_usb_bus():
# usb_live defaults ON: the default compose binds /dev/bus/usb (validated
# to boot rootless) but uses NO custom -qmp socket / device_cgroup_rules
# (both crash-looped boot). Live attach reuses dockur's own monitor.
from winpodx.core.config import Config
from winpodx.core.pod.compose import _build_compose_content
out = _build_compose_content(Config())
assert "- /dev/kvm" in out and "- /dev/net/tun" in out
assert "- /dev/bus/usb:/dev/bus/usb" in out
# SELinux hosts (openSUSE Tumbleweed / Fedora / RHEL) deny container_t
# read on the usbfs nodes even with matching uid/ACL — label=disable
# lifts that confinement (scoped to this one container). See #286.
assert "security_opt:" in out and "- label=disable" in out
assert "-qmp" not in out
assert "device_cgroup_rules" not in out
assert "usb-host" not in out # never boot-added
def test_compose_usb_live_off_is_clean():
from winpodx.core.config import Config
from winpodx.core.pod.compose import _build_compose_content
c = Config()
c.pod.usb_live = False
c.pod.__post_init__()
out = _build_compose_content(c)
assert "/dev/bus/usb" not in out
assert "-qmp" not in out
# No device exposure -> keep full SELinux confinement on the container.
assert "label=disable" not in out
def test_compose_usb_live_opt_in_wires_usb_bus_only():
# Explicit opt-in wires ONLY the /dev/bus/usb bind. Live attach reuses
# dockur's own `-monitor`, so NO custom -qmp socket/bind-mount (that
# crash-looped boot), NO device_cgroup_rules (rootless-incompatible), and
# USB is never boot-added.
from winpodx.core.config import Config
from winpodx.core.pod.compose import _build_compose_content
c = Config()
c.pod.usb_live = True
c.pod.__post_init__()
out = _build_compose_content(c)
assert "- /dev/bus/usb:/dev/bus/usb" in out
assert "- label=disable" in out # SELinux confinement lifted for usbfs access
assert "-qmp" not in out # reuse dockur's monitor, no custom socket
assert "qmp.sock" not in out # no bind-mounted socket
assert "device_cgroup_rules" not in out
assert "usb-host" not in out # USB never boot-added
def test_compose_pci_boot_adds_vfio_usb_stays_live():
from winpodx.core.config import Config
from winpodx.core.pod.compose import _build_compose_content
c = Config()
c.pod.usb_live = True # opt-in so the QMP socket is wired alongside PCI
c.pod.devices = ["usb|1234:5678|Dongle", "pci|0000:01:00.0|Card"]
c.pod.__post_init__()
out = _build_compose_content(c)
# PCI is boot-added (can't hot-plug into a container QEMU).
assert "- /dev/vfio/vfio" in out
assert "vfio-pci,host=0000:01:00.0" in out
# USB stays live-only even when assigned — never boot-added; the USB bus
# bind is present (live attach reuses dockur's monitor, no custom -qmp).
assert "usb-host" not in out
assert "- /dev/bus/usb:/dev/bus/usb" in out
assert "-qmp" not in out
# PCI (vfio) also needs the SELinux lift to open /dev/vfio.
assert "- label=disable" in out
def test_compose_pci_only_still_lifts_selinux():
# Even with usb_live off, an assigned PCI device exposes /dev/vfio/vfio,
# which container_t can't open on SELinux hosts -> label=disable applies.
from winpodx.core.config import Config
from winpodx.core.pod.compose import _build_compose_content
c = Config()
c.pod.usb_live = False
c.pod.devices = ["pci|0000:01:00.0|Card"]
c.pod.__post_init__()
out = _build_compose_content(c)
assert "- /dev/bus/usb" not in out # usb off
assert "- label=disable" in out # but PCI still needs the lift
def test_assign_device_persists_and_dedups(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
dc = D.DeviceConfig("usb", "1234:5678", "ACME Dongle")
assert D.assign_device(cfg, dc) is True
assert cfg.pod.devices == ["usb|1234:5678|ACME Dongle"]
# idempotent: same key -> no-op even with a different label
assert D.assign_device(cfg, D.DeviceConfig("usb", "1234:5678", "other")) is False
assert cfg.pod.devices == ["usb|1234:5678|ACME Dongle"]
# persisted to disk
assert "usb|1234:5678" in Config.path().read_text()
def test_unassign_device_removes_by_key(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
from winpodx.core.config import Config
cfg = Config()
D.assign_device(cfg, D.DeviceConfig("usb", "1234:5678", "ACME Dongle"))
# matches by key, ignores label
assert D.unassign_device(cfg, D.DeviceConfig("usb", "1234:5678", "different")) is True
assert cfg.pod.devices == []
# second call is a no-op
assert D.unassign_device(cfg, D.DeviceConfig("usb", "1234:5678")) is False