diff --git a/src/cli/install/phase.zig b/src/cli/install/phase.zig index b3c2174..bb9fe6c 100644 --- a/src/cli/install/phase.zig +++ b/src/cli/install/phase.zig @@ -354,6 +354,7 @@ pub fn uninstall(allocator: std.mem.Allocator, opts: InstallOptions) !void { "/lib/systemd/user/padctl.service", "/lib/udev/rules.d/60-padctl.rules", "/lib/udev/rules.d/61-padctl-driver-block.rules", + "/lib/udev/rules.d/62-padctl-reprobe.rules", "/lib/udev/rules.d/90-padctl.rules", "/lib/udev/rules.d/99-padctl.rules", "/lib/modules-load.d/padctl.conf", @@ -374,6 +375,7 @@ pub fn uninstall(allocator: std.mem.Allocator, opts: InstallOptions) !void { const etc_rules = [_][]const u8{ "/etc/udev/rules.d/60-padctl.rules", "/etc/udev/rules.d/61-padctl-driver-block.rules", + "/etc/udev/rules.d/62-padctl-reprobe.rules", "/etc/udev/rules.d/90-padctl.rules", "/etc/udev/rules.d/99-padctl.rules", "/etc/modules-load.d/padctl.conf", diff --git a/src/cli/install/tests.zig b/src/cli/install/tests.zig index 7876d10..c9757f5 100644 --- a/src/cli/install/tests.zig +++ b/src/cli/install/tests.zig @@ -60,6 +60,7 @@ const parseHexOrDec = _udev.parseHexOrDec; const generateUdevRules = _udev.generateUdevRules; const generateDriverBlockRules = _udev.generateDriverBlockRules; const generateDriverBlockRulesFromEntries = _udev.generateDriverBlockRulesFromEntries; +const generateReprobeRulesFromEntries = _udev.generateReprobeRulesFromEntries; const sentinelPath = udev_mod.sentinelPath; const runtime_sentinel_path = udev_mod.runtime_sentinel_path; const writeServiceSentinel = udev_mod.writeServiceSentinel; @@ -1940,6 +1941,84 @@ test "install: #137 generates ACTION==remove rebind line" { try testing.expect(std.mem.indexOf(u8, content, "/bind") != null); } +// FALSIFIABILITY: +// (A) FAILS if ATTRS{idVendor} value is wrong (e.g. "37d8" instead of "37d7") — +// the hard-coded expected string "37d7" will not be found in the output. +// (B) FAILS if the drivers_probe write target is removed or renamed — the +// substring "drivers_probe" will not appear in the output. +// (C) FAILS if the live driverless guard (`test -e`) is removed — the +// "test -e /sys%p/driver" substring will not match. +// (D) FAILS if ATTRS{idProduct} does not encode to "2401". +// (E) FAILS if ACTION is wrong (e.g. "add" only, missing "|bind"). +// (F) FAILS if DEVTYPE is wrong (e.g. "usb_device"). +test "install: #355 generateReprobeRulesFromEntries generates correct rule for Vader 5" { + const testing = std.testing; + const allocator = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(tmp_path); + + const rules_path = try std.fmt.allocPrint(allocator, "{s}/62.rules", .{tmp_path}); + defer allocator.free(rules_path); + + const entries = [_]UdevEntry{.{ + .name = "Flydigi Vader 5", + .vid = 0x37d7, + .pid = 0x2401, + }}; + try generateReprobeRulesFromEntries(allocator, &entries, rules_path); + + const content = try readRulesFile(allocator, rules_path); + defer allocator.free(content); + + // (A) vid match + try testing.expect(std.mem.indexOf(u8, content, "ATTRS{idVendor}==\"37d7\"") != null); + // (D) pid match + try testing.expect(std.mem.indexOf(u8, content, "ATTRS{idProduct}==\"2401\"") != null); + // (E) action includes |bind + try testing.expect(std.mem.indexOf(u8, content, "ACTION==\"add|bind\"") != null); + // (F) interface devtype + try testing.expect(std.mem.indexOf(u8, content, "ENV{DEVTYPE}==\"usb_interface\"") != null); + // (B) drivers_probe write target + try testing.expect(std.mem.indexOf(u8, content, "drivers_probe") != null); + // (C) live driverless guard + try testing.expect(std.mem.indexOf(u8, content, "test -e /sys%p/driver") != null); +} + +// FALSIFIABILITY: FAILS if the loop-safety condition is removed — a bound +// interface (driver symlink present) must NOT be re-probed. We verify the +// guard is literally present; removing it would cause spurious re-probes on +// already-bound interfaces (idem-potency breach). +test "install: #355 reprobe rule has live driverless guard on every entry line" { + const testing = std.testing; + const allocator = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(tmp_path); + + const rules_path = try std.fmt.allocPrint(allocator, "{s}/62.rules", .{tmp_path}); + defer allocator.free(rules_path); + + const entries = [_]UdevEntry{ + .{ .name = "Device A", .vid = 0x37d7, .pid = 0x2401 }, + .{ .name = "Device B", .vid = 0x0f0d, .pid = 0x00c1 }, + }; + try generateReprobeRulesFromEntries(allocator, &entries, rules_path); + + const content = try readRulesFile(allocator, rules_path); + defer allocator.free(content); + + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line| { + if (std.mem.indexOf(u8, line, "drivers_probe") == null) continue; + try testing.expect(std.mem.indexOf(u8, line, "test -e /sys%p/driver") != null); + } +} + test "install: staged driver-block udev rule uses runtime sentinel path" { const testing = std.testing; const allocator = testing.allocator; diff --git a/src/cli/install/udev.zig b/src/cli/install/udev.zig index f5565a0..346aa32 100644 --- a/src/cli/install/udev.zig +++ b/src/cli/install/udev.zig @@ -290,6 +290,14 @@ pub fn installUdevRules(allocator: std.mem.Allocator, plan: *const InstallPlan, const msg = std.fmt.bufPrint(&errbuf, "warning: driver block rules not generated: {}\n", .{err}) catch "warning: driver block rules error\n"; _ = std.posix.write(std.posix.STDERR_FILENO, msg) catch {}; }; + + const reprobe_rules_path = try std.fmt.allocPrint(allocator, "{s}/62-padctl-reprobe.rules", .{plan.udev_dir}); + defer allocator.free(reprobe_rules_path); + generateReprobeRulesFromEntries(allocator, entries, reprobe_rules_path) catch |err| { + var errbuf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&errbuf, "warning: reprobe rules not generated: {}\n", .{err}) catch "warning: reprobe rules error\n"; + _ = std.posix.write(std.posix.STDERR_FILENO, msg) catch {}; + }; } /// Mutate live kernel driver state to match the freshly written udev rules. @@ -332,6 +340,7 @@ pub fn cleanupLegacyUdevFiles(allocator: std.mem.Allocator, plan: *const Install const basenames = [_][]const u8{ "60-padctl.rules", "61-padctl-driver-block.rules", + "62-padctl-reprobe.rules", "90-padctl.rules", "99-padctl.rules", }; @@ -558,6 +567,36 @@ pub fn generateDriverBlockRulesFromEntries(allocator: std.mem.Allocator, entries try f.writeAll(buf.items); } +/// Generate 62-padctl-reprobe.rules: one rule per entry that re-probes a +/// driverless USB interface at coldplug/hotplug so a device already attached at +/// boot gets a hidraw node without a physical replug. +/// +/// The RUN+= shell snippet live-checks `test -e /sys%p/driver` (%p/%k are +/// substituted by udevd, matching the sibling driver-block rule) at execution +/// time rather than guarding on ENV{DRIVER}=="" (stale env). An +/// already-bound interface passes the test and the write is skipped; a driverless +/// interface fails and the interface name is written to drivers_probe. The +/// drivers_probe write is itself idempotent. +pub fn generateReprobeRulesFromEntries(allocator: std.mem.Allocator, entries: []const UdevEntry, rules_path: []const u8) !void { + var buf = std.ArrayList(u8){}; + defer buf.deinit(allocator); + try buf.appendSlice(allocator, "# Auto-generated by padctl install — boot reprobe rules\n"); + + for (entries) |e| { + const line = try std.fmt.allocPrint( + allocator, + "ACTION==\"add|bind\", SUBSYSTEM==\"usb\", ENV{{DEVTYPE}}==\"usb_interface\", ATTRS{{idVendor}}==\"{x:0>4}\", ATTRS{{idProduct}}==\"{x:0>4}\", RUN+=\"/bin/sh -c 'test -e /sys%p/driver || echo %k > /sys/bus/usb/drivers_probe'\"\n# {s}\n", + .{ e.vid, e.pid, e.name }, + ); + defer allocator.free(line); + try buf.appendSlice(allocator, line); + } + + var f = try std.fs.createFileAbsolute(rules_path, .{ .truncate = true }); + defer f.close(); + try f.writeAll(buf.items); +} + /// Collects entries then generates driver block rules. fn generateDriverBlockRules(allocator: std.mem.Allocator, dirs: []const []const u8, rules_path: []const u8) !void { var entries = try collectDeviceEntries(allocator, dirs); @@ -989,6 +1028,7 @@ pub const _internals_for_tests = struct { pub const generateUdevRules = @import("udev.zig").generateUdevRules; pub const generateDriverBlockRules = @import("udev.zig").generateDriverBlockRules; pub const generateDriverBlockRulesFromEntries = @import("udev.zig").generateDriverBlockRulesFromEntries; + pub const generateReprobeRulesFromEntries = @import("udev.zig").generateReprobeRulesFromEntries; }; // setupTestUdev writes a udev rule that grants world-read access to UHID virtual