aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_install_camera_passthrough_rules.py
blob: ab28575f066d329919091fe20e5e2fd05c63d9b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
"""Test install_camera_passthrough_rules — the VM camera udev grant.

usbredirect must open a camera's raw USB node read-write to claim it for the
Windows VM; the node defaults to root-owned with no group write, so the
attach fails with a bare "Failed to open device!". The rule grants
GROUP="video", MODE="0660" (the verified fix) and keeps TAG+="uaccess".

The file NUMBER is load-bearing (winvm correction, 2026-08-08): the uaccess
ACL is applied by 73-seat-late.rules, so a 99- file adds the tag after that
already ran. The shipped filename must sort below 73.

Method: sed-extract install_camera_passthrough_rules from the real
`archsetup`, point it at a temp rules path, fake display / error_warn.

Run from repo root:
    python3 -m unittest tests.installer-steps.test_install_camera_passthrough_rules
"""

import os
import re
import stat
import subprocess
import tempfile
import textwrap
import unittest

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")


def run(rules_path, pre=""):
    script = textwrap.dedent(f"""\
        logfile=/dev/null
        action=""
        display() {{ :; }}
        error_warn() {{ echo "WARN: $1"; return 1; }}
        source <(sed -n '/^install_camera_passthrough_rules() {{/,/^}}/p' "{ARCHSETUP}")
        {pre}
        install_camera_passthrough_rules "{rules_path}"
        echo "RC=$?"
        echo "RULES:[$(cat "{rules_path}" 2>/dev/null)]"
        exit 0
    """)
    return subprocess.run(
        ["bash", "-c", script], capture_output=True, text=True, timeout=10,
    )


def rules_body(r):
    return r.stdout.split("RULES:[")[1].split("]")[0]


class InstallCameraPassthroughRules(unittest.TestCase):
    # ------------------------------------------------------------ normal ----
    def test_writes_both_camera_rules_with_group_mode_and_tag(self):
        with tempfile.TemporaryDirectory() as d:
            r = run(os.path.join(d, "72-test.rules"))
            body = rules_body(r)
            for vendor, product in (("3564", "ff02"), ("046d", "085e")):
                line = next((ln for ln in body.splitlines()
                             if f'ATTR{{idVendor}}=="{vendor}"' in ln), None)
                assert line is not None, f"no rule line for {vendor}:{product}"
                self.assertIn(f'ATTR{{idProduct}}=="{product}"', line)
                self.assertIn('GROUP="video"', line)
                self.assertIn('MODE="0660"', line)
                self.assertIn('TAG+="uaccess"', line)
            self.assertIn("RC=0", r.stdout)

    def test_default_filename_sorts_below_seat_late(self):
        # The rule file's default install path must sort before
        # 73-seat-late.rules or the uaccess tag lands too late to be ACLed.
        with open(ARCHSETUP) as f:
            src = f.read()
        func = re.search(
            r'^install_camera_passthrough_rules\(\)\s*{.*?^}', src, re.S | re.M)
        assert func is not None, "function not found in archsetup"
        m = re.search(r'\$\{1:-(/etc/udev/rules\.d/[^}]+)\}', func.group(0))
        assert m is not None, "default rules path not found in the function"
        basename = os.path.basename(m.group(1))
        self.assertLess(basename, "73-seat-late.rules",
                        "the rules file must sort below 73-seat-late.rules")

    # ---------------------------------------------------------- boundary ----
    def test_rerun_is_idempotent(self):
        with tempfile.TemporaryDirectory() as d:
            path = os.path.join(d, "72-test.rules")
            first = run(path)
            body_one = rules_body(first)
            second = run(path)
            self.assertEqual(body_one, rules_body(second))
            self.assertIn("RC=0", second.stdout)

    def test_overwrites_a_stale_existing_file(self):
        with tempfile.TemporaryDirectory() as d:
            path = os.path.join(d, "72-test.rules")
            with open(path, "w") as f:
                f.write("# stale content that must not survive\n")
            r = run(path)
            self.assertNotIn("stale content", rules_body(r))
            self.assertIn('GROUP="video"', rules_body(r))

    # ------------------------------------------------------------- error ----
    @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
    def test_unwritable_dir_warns_and_does_not_crash(self):
        with tempfile.TemporaryDirectory() as d:
            locked = os.path.join(d, "locked")
            os.makedirs(locked)
            os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR)
            r = run(os.path.join(locked, "72-test.rules"))
            os.chmod(locked, stat.S_IRWXU)
            self.assertIn("WARN:", r.stdout)
            self.assertIn("RC=", r.stdout,
                          "the harness must reach its RC line — the function "
                          "returned rather than aborting the script")


if __name__ == "__main__":
    unittest.main()