From 84514efb035c630c6d21305cb0cf81115915d6ed Mon Sep 17 00:00:00 2001 From: Shen Mintao Date: Mon, 2 Nov 2026 14:23:48 +0800 Subject: [PATCH 1/4] docs: record upstream alignment and tooling review policy (cherry picked from commit 0fbb11a47c768c01efd4de88ebfb97604368979e) --- AGENTS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9caf735 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# 维护原则 + +本文件适用于整个仓库。 + +## 与上游保持一致 + +- 对继承自 Radxa/AIC 上游的驱动、固件、加载协议和运行行为,默认保持一致,优先采用上游已经接受的修复。 +- 对这些上游组件,除非修复有证据支持的重要 bug,否则不引入会增加下游维护负担的改动。 +- 重要 bug 包括实际影响设备功能、稳定性、安全性或受支持内核构建的问题。仅凭代码风格、理论风险或“体验更好”不足以认定为重要 bug。 +- 修复应尽量小且独立,便于审查和同步未来上游版本。避免顺带重构、清理、增加功能或建立新的策略与兼容框架。 + +## 本仓库维护的工具 + +- 安装脚本、打包、诊断和文档应与上游驱动改动分开评估;不能仅因上游没有同类实现就否决。 +- 这些改动仍应简单、必要、有验证,避免不必要的默认行为和维护成本。 +- 试编译应评估失败时能否保住已有安装及额外编译耗时;固件备份应评估恢复价值、重复备份和磁盘占用,避免无限累积。它们本身不构成驱动或固件偏离上游。 + +## PR 和 issue 处理 + +- 先核实当前分支、上游实现和最新讨论,避免重复处理已合并或仍在等待实机验证的问题。 +- 不因 PR 使用了 AI、描述完整或声称测试通过而接受或拒绝;以可核实的问题、实际代码、独立验证及维护成本为依据。 +- 合入前说明问题的触发条件、影响、与上游的差异和验证结果;证据不足时继续保留为待验证项。 +- 编译通过、模拟测试和硬件测试必须明确区分,不得用前两者替代实机结论。 +- 涉及两个维护分支的必要修复应分别评估并同步,保留 `main` 与 `legacy-mcu1` 各自匹配的固件和加载配置,不能直接混合固件或整体合并分支。 +- 用户本次会话的明确指示优先于本文件。 From f4065a8c895cc614534f6e65bd75837eb1a1a367 Mon Sep 17 00:00:00 2001 From: rtk94 Date: Sun, 1 Nov 2026 16:12:45 -0500 Subject: [PATCH 2/4] Write the installer log to /var/log instead of a world-writable /tmp file install.sh ran as root and created /tmp/aic8800d80_install.log with mode 666 at a fixed, predictable path. Root writing to a fixed name in /tmp is open to symlink races (mitigated, not removed, by fs.protected_symlinks), and a world-writable log lets any local user alter what an administrator later reads. The log now lives at /var/log/aic8800-install.log, is created with mode 0644 only after the root check, and failed writes before that point are silenced instead of printing redirection errors. diagnostic_build.sh reads the new path and falls back to the old one for logs left by earlier installers. (cherry picked from commit ef66811ca501c8124de99c7f52e4792378c88d36) (cherry picked from commit 33d33afe9655c9154306ec851e5e7aa62147062f) --- diagnostic_build.sh | 6 +++++- install.sh | 23 +++++++++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/diagnostic_build.sh b/diagnostic_build.sh index 3c702c2..edf84a0 100755 --- a/diagnostic_build.sh +++ b/diagnostic_build.sh @@ -19,7 +19,11 @@ NC='\033[0m' DRV_NAME="aic8800" DRV_VERSION="1.0.0" -INSTALL_LOG="/tmp/aic8800d80_install.log" +INSTALL_LOG="/var/log/aic8800-install.log" +# Installers before this change wrote their log to /tmp. +if [ ! -f "$INSTALL_LOG" ] && [ -f /tmp/aic8800d80_install.log ]; then + INSTALL_LOG="/tmp/aic8800d80_install.log" +fi read_dkms_conf_value() { local key="$1" diff --git a/install.sh b/install.sh index 15afaa9..1a9e6ab 100755 --- a/install.sh +++ b/install.sh @@ -24,7 +24,7 @@ readonly DRV_NAME="aic8800" readonly DRV_VERSION="1.0.0" readonly SRC_DIR="/usr/src/${DRV_NAME}-${DRV_VERSION}" readonly MODULE_NAME="aic8800_fdrv" -readonly LOG_FILE="/tmp/aic8800d80_install.log" +readonly LOG_FILE="/var/log/aic8800-install.log" # Script directory (where the script is located) readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -36,12 +36,7 @@ readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log_message() { local level="$1" shift - # Garantir que o arquivo de log existe e tem permissões corretas - if [ ! -f "$LOG_FILE" ]; then - touch "$LOG_FILE" 2>/dev/null || true - chmod 666 "$LOG_FILE" 2>/dev/null || true - fi - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_FILE" 2>/dev/null || true + { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_FILE"; } 2>/dev/null || true } print_info() { @@ -823,11 +818,14 @@ show_final_instructions() { ############################################################################# main() { - # Initialize log file + # Step 1: Check root privileges (before touching the log, which lives in + # /var/log and is written by root only) + check_root + + # Initialize log file: owned by root, readable by all, never world-writable rm -f "$LOG_FILE" 2>/dev/null || true - touch "$LOG_FILE" 2>/dev/null || true - chmod 666 "$LOG_FILE" 2>/dev/null || true - + install -m 0644 /dev/null "$LOG_FILE" 2>/dev/null || true + echo -e "${CYAN}" echo "╔════════════════════════════════════════════════════════════════╗" echo "║ AIC8800D80 WiFi 6 Driver - Universal Installer (DKMS) ║" @@ -839,9 +837,6 @@ main() { log_message "INFO" "Kernel: $(uname -r)" log_message "INFO" "Distribution: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '"')" - # Step 1: Check root privileges - check_root - # Step 2: Check Secure Boot check_secure_boot From 20fb7181e6ac4f34ec953acb943a94cced6ac0ce Mon Sep 17 00:00:00 2001 From: Shen Mintao Date: Mon, 2 Nov 2026 14:31:50 +0800 Subject: [PATCH 3/4] fix(installer): test-build before replacing the active installation Adapt the preflight protection from #128 without its firmware backup or other installer policy changes. Build a clean private copy against the running kernel before firmware replacement or DKMS removal. Treat log initialization failure as an error and add mocked main-flow regressions. Co-authored-by: rtk94 (cherry picked from commit bee40068b90ab4929d7abd4596da3d19619ec0c3) --- INSTALL_SCRIPT.md | 8 +++ install.sh | 31 +++++++- tests/installer/test_install.py | 124 ++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/INSTALL_SCRIPT.md b/INSTALL_SCRIPT.md index 5ba31c3..80eabc8 100644 --- a/INSTALL_SCRIPT.md +++ b/INSTALL_SCRIPT.md @@ -35,6 +35,14 @@ cannot download dependencies because the database is stale, run `sudo pacman -Syu` yourself, reboot if the kernel was updated, and rerun the installer with headers matching the running kernel. +Before replacing installed firmware or DKMS modules, the installer cleans and +test-builds a temporary copy of the driver for the running kernel. A test-build +failure leaves the existing firmware, DKMS registration and installed source +untouched. A successful installation compiles again through DKMS, so this check +adds build time. Logs are written to `/var/log/aic8800-install.log` with mode +`0644` after the root check. This check does not provide automatic rollback if a +later installation step fails. + The USB modules declare the complete firmware set shipped in `fw/`, including configuration files and extension patches, so initramfs tools can include those files when they include the modules. Rerun the installer after updating the diff --git a/install.sh b/install.sh index 1a9e6ab..6193589 100755 --- a/install.sh +++ b/install.sh @@ -499,6 +499,30 @@ EOF print_success "DKMS configuration created." } +############################################################################# +# Pre-flight Build +############################################################################# + +preflight_build() { + local build_root kver + kver="$(uname -r)" + print_step "Test-building the driver for $kver before replacing the existing installation..." + + build_root=$(mktemp -d "${TMPDIR:-/tmp}/aic8800-preflight.XXXXXX") + # Use a private copy and clean it so a previous build cannot hide an error. + if cp -r "${SCRIPT_DIR}/drivers" "$build_root/" >> "$LOG_FILE" 2>&1 && + make -C "$build_root/drivers/aic8800" KVER="$kver" KDIR="/lib/modules/$kver/build" clean >> "$LOG_FILE" 2>&1 && + make -C "$build_root/drivers/aic8800" KVER="$kver" KDIR="/lib/modules/$kver/build" >> "$LOG_FILE" 2>&1; then + rm -rf "$build_root" + print_success "Test build succeeded." + else + rm -rf "$build_root" + print_error "Test build failed for kernel $kver. Existing firmware and DKMS installation were not changed." + echo "See $LOG_FILE for the compiler output." + return 1 + fi +} + ############################################################################# # DKMS Installation ############################################################################# @@ -823,8 +847,8 @@ main() { check_root # Initialize log file: owned by root, readable by all, never world-writable - rm -f "$LOG_FILE" 2>/dev/null || true - install -m 0644 /dev/null "$LOG_FILE" 2>/dev/null || true + rm -f "$LOG_FILE" + install -m 0644 /dev/null "$LOG_FILE" echo -e "${CYAN}" echo "╔════════════════════════════════════════════════════════════════╗" @@ -849,7 +873,8 @@ main() { # Step 5: Check kernel build tree check_kernel_build_tree - # Step 6: Install firmware + # Step 6: Test-build before changing installed firmware or DKMS modules + preflight_build install_firmware # Step 7: Create DKMS configuration diff --git a/tests/installer/test_install.py b/tests/installer/test_install.py index 4a9e408..54ab95d 100644 --- a/tests/installer/test_install.py +++ b/tests/installer/test_install.py @@ -30,6 +30,8 @@ class InstallerTests(unittest.TestCase): def run_installer_function(self, name, stubs="", after=""): # Extract only the functions under test; never execute main(). names = ["run_logged", "cleanup_on_error", name] + if name == "main" and re.search(r"(?m)^preflight_build\(\)", SOURCE): + names.insert(-1, "preflight_build") functions = [] for function in names: match = re.search(r"(?ms)^" + function + r"\(\) \{.*?^\}", SOURCE) @@ -42,6 +44,10 @@ class InstallerTests(unittest.TestCase): {SHELL_OPTIONS} SCRIPT_DIR={shlex.quote(str(self.root))} LOG_FILE="$SCRIPT_DIR/install.log" +SRC_DIR="$SCRIPT_DIR/installed-source" +TMPDIR="$SCRIPT_DIR/tmp" +export TMPDIR +mkdir -p "$TMPDIR" DRV_NAME=aic8800 DRV_VERSION=1.0.0 MODULE_NAME=aic8800_fdrv @@ -50,6 +56,7 @@ print_info() {{ echo "$*"; }} print_success() {{ echo "$*"; }} print_warning() {{ echo "$*"; }} print_error() {{ echo "$*"; }} +log_message() {{ :; }} dkms() {{ echo 'aic8800/1.0.0: installed'; }} lsmod() {{ :; }} aic_zlp_target_present() {{ return 1; }} @@ -132,6 +139,123 @@ cleanup_legacy_bluetooth_config() {{ echo LEGACY_CLEANUP; }} self.assertFalse(old.exists()) self.assertEqual((old.parent / "new.bin").read_bytes(), b"new") + def prepare_preflight(self): + source = self.root / "drivers" / "aic8800" / "source.c" + source.parent.mkdir(parents=True) + source.write_bytes(b"source") + old = self.firmware / "aic8800D80" / "old.bin" + old.parent.mkdir() + old.write_bytes(b"installed firmware") + installed = self.root / "installed-source" / "revision" + installed.parent.mkdir() + installed.write_bytes(b"installed source") + (self.root / "dkms-state").write_bytes(b"installed modules") + return r''' +uname() { echo test-kernel; } +check_root() { :; } +check_secure_boot() { :; } +detect_package_manager() { DETECTED_PKG_MANAGER=apt; } +install_dependencies() { :; } +check_kernel_build_tree() { :; } +create_dkms_conf() { :; } +refresh_initramfs() { :; } +load_module() { :; } +verify_installation() { :; } +show_final_instructions() { echo FINAL_INSTRUCTIONS; } +make() { + echo "MAKE $*" >> "$SCRIPT_DIR/events" + [ "$(cat "$2/source.c")" = source ] || return 99 + case "$*" in + *' clean') return "${MAKE_CLEAN_STATUS:-0}" ;; + esac + echo built > "$2/source.c" + return "${MAKE_BUILD_STATUS:-0}" +} +install_firmware() { + echo FIRMWARE >> "$SCRIPT_DIR/events" + echo changed > "$SCRIPT_DIR/firmware/aic8800D80/old.bin" +} +install_via_dkms() { + echo DKMS >> "$SCRIPT_DIR/events" + echo changed > "$SRC_DIR/revision" + echo changed > "$SCRIPT_DIR/dkms-state" +} +''' + + def assert_installation_preserved(self, result): + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual((self.firmware / "aic8800D80/old.bin").read_bytes(), b"installed firmware") + self.assertEqual((self.root / "installed-source/revision").read_bytes(), b"installed source") + self.assertEqual((self.root / "dkms-state").read_bytes(), b"installed modules") + events = self.root / "events" + if events.exists(): + self.assertNotIn("FIRMWARE", events.read_text()) + self.assertNotIn("DKMS", events.read_text()) + self.assertNotIn("FINAL_INSTRUCTIONS", result.stdout) + self.assertEqual(list((self.root / "tmp").iterdir()), []) + + def test_failed_preflight_preserves_firmware_source_and_dkms(self): + stubs = self.prepare_preflight() + "\nMAKE_BUILD_STATUS=17\n" + result = self.run_installer_function("main", stubs) + self.assert_installation_preserved(result) + self.assertIn("Test build failed", result.stdout) + self.assertIn("MAKE", (self.root / "events").read_text()) + self.assertEqual((self.root / "drivers/aic8800/source.c").read_bytes(), b"source") + + def test_preflight_copy_failure_preserves_installation(self): + stubs = self.prepare_preflight() + "\ncp() { return 18; }\n" + result = self.run_installer_function("main", stubs) + self.assert_installation_preserved(result) + self.assertFalse((self.root / "events").exists()) + + def test_preflight_clean_failure_preserves_installation(self): + stubs = self.prepare_preflight() + "\nMAKE_CLEAN_STATUS=19\n" + result = self.run_installer_function("main", stubs) + self.assert_installation_preserved(result) + self.assertEqual(len((self.root / "events").read_text().splitlines()), 1) + + def test_preflight_success_precedes_installation_and_cleans_temp(self): + result = self.run_installer_function("main", self.prepare_preflight()) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + events = (self.root / "events").read_text().splitlines() + self.assertEqual(len(events), 4) + self.assertTrue(events[0].endswith(" clean"), events) + self.assertTrue(events[1].startswith("MAKE "), events) + self.assertEqual(events[2:], ["FIRMWARE", "DKMS"]) + for line in events[:2]: + self.assertIn(str(self.root / "tmp"), line) + self.assertIn("KVER=test-kernel KDIR=/lib/modules/test-kernel/build", line) + self.assertEqual((self.root / "drivers/aic8800/source.c").read_bytes(), b"source") + self.assertEqual(list((self.root / "tmp").iterdir()), []) + self.assertIn("FINAL_INSTRUCTIONS", result.stdout) + + def test_log_replaces_symlink_without_overwriting_target(self): + stubs = self.prepare_preflight() + target = self.root / "keep.log" + target.write_bytes(b"keep this file") + (self.root / "install.log").symlink_to(target) + result = self.run_installer_function("main", stubs) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(target.read_bytes(), b"keep this file") + log = self.root / "install.log" + self.assertFalse(log.is_symlink()) + self.assertEqual(log.stat().st_mode & 0o777, 0o644) + + def test_root_check_precedes_log_changes(self): + stubs = self.prepare_preflight() + "\ncheck_root() { return 20; }\n" + log = self.root / "install.log" + log.write_bytes(b"previous log") + result = self.run_installer_function("main", stubs) + self.assert_installation_preserved(result) + self.assertEqual(log.read_bytes(), b"previous log") + self.assertFalse((self.root / "events").exists()) + + def test_log_creation_failure_stops_before_preflight(self): + stubs = self.prepare_preflight() + "\ninstall() { return 21; }\n" + result = self.run_installer_function("main", stubs) + self.assert_installation_preserved(result) + self.assertFalse((self.root / "events").exists()) + if __name__ == "__main__": unittest.main(verbosity=2) From 3a71c7188be34c1fa23336939bd074be8d0367c0 Mon Sep 17 00:00:00 2001 From: Shen Mintao Date: Mon, 2 Nov 2026 14:31:52 +0800 Subject: [PATCH 4/4] 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)