From 3a71c7188be34c1fa23336939bd074be8d0367c0 Mon Sep 17 00:00:00 2001 From: Shen Mintao Date: Mon, 2 Nov 2026 14:31:52 +0800 Subject: [PATCH] fix(udev): restrict mode switching to intended devices and events Take the aic.rules portion of #129: restrict 1111:1111 switching to 88M80 USB devices, target the event's bus/device address, and eject only whole disks on add. Add selection fixtures and document the product restriction. Keep installer paths and existing firmware handling unchanged. Co-authored-by: rtk94 (cherry picked from commit e2c58357679daf534f4dd229b325283711918d85) --- README.md | 5 +++ aic.rules | 26 ++++++++------ tests/installer/test_udev.py | 69 ++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 tests/installer/test_udev.py diff --git a/README.md b/README.md index 12caed0..f45f243 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ commands. Existing users must rerun `sudo ./install.sh` to replace the installed usb_modeswitch configuration, then unplug the adapter completely and plug it back in. +Automatic switching requires the USB product string to start with `88M80`. +Unrelated flash disks also use `1111:1111`, so that USB ID alone is not enough. +If your adapter reports a different product string, verify its identity before +trying the manual command below. + If automatic switching does not occur, try the same sequence manually: ```bash diff --git a/aic.rules b/aic.rules index 5530f48..1d2be95 100644 --- a/aic.rules +++ b/aic.rules @@ -1,14 +1,18 @@ # AIC8800 USB mass-storage mode switch rules -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5721", SYMLINK+="aicudisk", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5722", SYMLINK+="aicudisk", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5723", SYMLINK+="tendaudisk", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5724", SYMLINK+="ugreenax900", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5725", SYMLINK+="tendaudiskv2", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5726", SYMLINK+="tendaudiskv3", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5727", SYMLINK+="tendaudiskv4", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572a", SYMLINK+="tendaudiskv5", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572c", SYMLINK+="cudydiskv2", RUN+="/usr/bin/eject /dev/%k" -KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572f", SYMLINK+="aicudisk572f", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5721", SYMLINK+="aicudisk", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5722", SYMLINK+="aicudisk", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5723", SYMLINK+="tendaudisk", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5724", SYMLINK+="ugreenax900", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5725", SYMLINK+="tendaudiskv2", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5726", SYMLINK+="tendaudiskv3", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="5727", SYMLINK+="tendaudiskv4", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572a", SYMLINK+="tendaudiskv5", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572c", SYMLINK+="cudydiskv2", RUN+="/usr/bin/eject /dev/%k" +ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sd*", ATTRS{idVendor}=="a69c", ATTRS{idProduct}=="572f", SYMLINK+="aicudisk572f", RUN+="/usr/bin/eject /dev/%k" # AIC8800D80 "Pandora" clone: switch 1111:1111 to its operational USB ID. -ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1111", ATTR{idProduct}=="1111", RUN+="/bin/sh -c 'if [ -x /usr/sbin/usb_modeswitch ]; then exec /usr/sbin/usb_modeswitch -c /etc/usb_modeswitch.d/1111:1111; elif [ -x /usr/bin/usb_modeswitch ]; then exec /usr/bin/usb_modeswitch -c /etc/usb_modeswitch.d/1111:1111; fi'" +# 1111:1111 is a placeholder ID that other devices also report (some AIC +# dongles expose a separate 1111:1111 "FLASH" driver disk next to an +# already working Wi-Fi function), so only devices whose product string is +# 88M80 are switched, and usb_modeswitch is pointed at this exact device. +ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="1111", ATTR{idProduct}=="1111", ATTR{product}=="88M80*", RUN+="/bin/sh -c 'if [ -x /usr/sbin/usb_modeswitch ]; then exec /usr/sbin/usb_modeswitch -b $attr{busnum} -g $attr{devnum} -c /etc/usb_modeswitch.d/1111:1111; elif [ -x /usr/bin/usb_modeswitch ]; then exec /usr/bin/usb_modeswitch -b $attr{busnum} -g $attr{devnum} -c /etc/usb_modeswitch.d/1111:1111; fi'" diff --git a/tests/installer/test_udev.py b/tests/installer/test_udev.py new file mode 100644 index 0000000..9338d5e --- /dev/null +++ b/tests/installer/test_udev.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Check device/event selection using fixtures; does not switch real devices. + +This models the basic equality/glob predicates used by aic.rules. Run +udevadm verify separately to validate syntax with the real udev parser. +""" + +from fnmatch import fnmatchcase +from pathlib import Path +import re +import unittest + + +RULES = (Path(__file__).resolve().parents[2] / "aic.rules").read_text().splitlines() + + +def matching_rules(device): + result = [] + for rule in RULES: + if not rule.strip() or rule.lstrip().startswith("#"): + continue + predicates = re.findall(r'([A-Z]+(?:\{[^}]+\})?)=="([^"]*)"', rule) + if predicates and all(fnmatchcase(device.get(key, ""), value) for key, value in predicates): + result.append(rule) + return result + + +class UdevTests(unittest.TestCase): + def pandora(self, **changes): + device = {"ACTION": "add", "SUBSYSTEM": "usb", "ENV{DEVTYPE}": "usb_device", + "ATTR{idVendor}": "1111", "ATTR{idProduct}": "1111", "ATTR{product}": "88M80"} + device.update(changes) + return device + + def test_88m80_uses_event_bus_and_device_numbers(self): + for product in ("88M80", "88M80 USB WiFi"): + with self.subTest(product=product): + matches = matching_rules(self.pandora(**{"ATTR{product}": product})) + self.assertEqual(len(matches), 1) + # Both possible usb_modeswitch executable locations target this event's device. + self.assertEqual(matches[0].count("-b $attr{busnum} -g $attr{devnum}"), 2) + + def test_generic_1111_flash_disk_is_not_switched(self): + for product in ("FLASH UDISK 2.30", "USB DISK", ""): + with self.subTest(product=product): + self.assertEqual(matching_rules(self.pandora(**{"ATTR{product}": product})), []) + + def test_wrong_ids_interfaces_and_non_add_events_are_not_switched(self): + for changes in ({"ACTION": "change"}, {"ACTION": "remove"}, + {"ENV{DEVTYPE}": "usb_interface"}, {"ATTR{idVendor}": "2222"}, + {"ATTR{idProduct}": "8d80"}): + with self.subTest(changes=changes): + self.assertEqual(matching_rules(self.pandora(**changes)), []) + + def test_eject_only_on_add_of_a_whole_supported_disk(self): + for product in ("5721", "5722", "5723", "5724", "5725", "5726", "5727", "572a", "572c", "572f"): + disk = {"ACTION": "add", "SUBSYSTEM": "block", "ENV{DEVTYPE}": "disk", "KERNEL": "sdb", + "ATTRS{idVendor}": "a69c", "ATTRS{idProduct}": product} + with self.subTest(product=product): + matches = matching_rules(disk) + self.assertEqual(len(matches), 1) + self.assertIn('/usr/bin/eject /dev/%k', matches[0]) + for changes in ({"ACTION": "change"}, {"ACTION": "remove"}, + {"ENV{DEVTYPE}": "partition", "KERNEL": "sdb1"}): + self.assertEqual(matching_rules(dict(disk, **changes)), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2)