From 1709cb9ab76a7ebcc28bc100e131eaab4da58954 Mon Sep 17 00:00:00 2001 From: Trey Turner Date: Fri, 17 Jul 2026 18:30:36 -0500 Subject: [PATCH] feat(linux): install PN532Killer udev rules --- .../conf/60-zaparoo-pn532killer.rules | 2 + pkg/platforms/linux/installer/install.go | 105 +++++++++++++---- pkg/platforms/linux/installer/install_test.go | 107 ++++++++++++++++++ 3 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 pkg/platforms/linux/installer/conf/60-zaparoo-pn532killer.rules diff --git a/pkg/platforms/linux/installer/conf/60-zaparoo-pn532killer.rules b/pkg/platforms/linux/installer/conf/60-zaparoo-pn532killer.rules new file mode 100644 index 000000000..a354382db --- /dev/null +++ b/pkg/platforms/linux/installer/conf/60-zaparoo-pn532killer.rules @@ -0,0 +1,2 @@ +# Allow user access to PN532Killer USB UART readers +SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d3", ATTRS{product}=="PN532Killer-UART", MODE="0660", TAG+="uaccess" diff --git a/pkg/platforms/linux/installer/install.go b/pkg/platforms/linux/installer/install.go index 5a428747a..3f945e784 100644 --- a/pkg/platforms/linux/installer/install.go +++ b/pkg/platforms/linux/installer/install.go @@ -41,6 +41,9 @@ var modprobeFile string //go:embed conf/60-zaparoo.rules var udevFile string +//go:embed conf/60-zaparoo-pn532killer.rules +var pn532KillerUdevFile string + //go:embed conf/zaparoo.service var systemdServiceFile string @@ -63,10 +66,23 @@ var icon128 []byte var icon256 []byte const ( - modprobePath = "/etc/modprobe.d/blacklist-zaparoo.conf" - udevPath = "/etc/udev/rules.d/60-zaparoo.rules" + modprobePath = "/etc/modprobe.d/blacklist-zaparoo.conf" + udevPath = "/etc/udev/rules.d/60-zaparoo.rules" + pn532KillerUdevPath = "/etc/udev/rules.d/60-zaparoo-pn532killer.rules" ) +type udevRuleFile struct { + path string + content string +} + +func managedUdevRuleFiles() []udevRuleFile { + return []udevRuleFile{ + {path: udevPath, content: udevFile}, + {path: pn532KillerUdevPath, content: pn532KillerUdevFile}, + } +} + // InstallApplication installs application files (binary, application launcher entry, icon). // Does not install systemd service or desktop shortcut. Must NOT be run as root. func InstallApplication() error { @@ -288,19 +304,8 @@ func doInstallHardware(cmd command.Executor) error { return errors.New("hardware install must be run as root") } - // install udev rules - if _, err := os.Stat(filepath.Dir(udevPath)); os.IsNotExist(err) { - return errors.New("udev rules directory does not exist") - } else if _, err := os.Stat(udevPath); os.IsNotExist(err) { - err = os.WriteFile(udevPath, []byte(udevFile), 0o644) //nolint:gosec // udev rules need to be readable by system - if err != nil { - return fmt.Errorf("error creating udev rules: %w", err) - } - // these are just for convenience, don't care too much if they fail - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _ = cmd.Run(ctx, "udevadm", "control", "--reload-rules") - _ = cmd.Run(ctx, "udevadm", "trigger") - cancel() + if err := installUdevRules(cmd, managedUdevRuleFiles()); err != nil { + return err } // install modprobe blacklist @@ -321,6 +326,43 @@ func doInstallHardware(cmd command.Executor) error { return nil } +func installUdevRules(cmd command.Executor, rules []udevRuleFile) error { + changed := false + for _, rule := range rules { + if _, err := os.Stat(filepath.Dir(rule.path)); err != nil { + if os.IsNotExist(err) { + return errors.New("udev rules directory does not exist") + } + return fmt.Errorf("error checking udev rules directory: %w", err) + } + + if _, err := os.Stat(rule.path); err == nil { + continue + } else if !os.IsNotExist(err) { + return fmt.Errorf("error checking udev rules %s: %w", rule.path, err) + } + + //nolint:gosec // udev rules need to be readable by the system + if err := os.WriteFile(rule.path, []byte(rule.content), 0o644); err != nil { + return fmt.Errorf("error creating udev rules %s: %w", rule.path, err) + } + changed = true + } + + if changed { + reloadUdevRules(cmd) + } + return nil +} + +func reloadUdevRules(cmd command.Executor) { + // These are convenience commands; the rule files remain installed if either command fails. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = cmd.Run(ctx, "udevadm", "control", "--reload-rules") + _ = cmd.Run(ctx, "udevadm", "trigger") +} + // UninstallApplication removes application files (binary, application launcher entry, icon). // Does not remove systemd service or desktop shortcut. Must NOT be run as root. func UninstallApplication() error { @@ -457,18 +499,31 @@ func doUninstallHardware(cmd command.Executor) error { cancel() } - // remove udev rules - if _, err := os.Stat(udevPath); !os.IsNotExist(err) { - err = os.Remove(udevPath) - if err != nil { - return fmt.Errorf("error removing udev rules: %w", err) + if err := uninstallUdevRules(cmd, managedUdevRuleFiles()); err != nil { + return err + } + + return nil +} + +func uninstallUdevRules(cmd command.Executor, rules []udevRuleFile) error { + changed := false + for _, rule := range rules { + if _, err := os.Stat(rule.path); err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("error checking udev rules %s: %w", rule.path, err) } - // these are just for convenience, don't care too much if they fail - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - _ = cmd.Run(ctx, "udevadm", "control", "--reload-rules") - _ = cmd.Run(ctx, "udevadm", "trigger") - cancel() + + if err := os.Remove(rule.path); err != nil { + return fmt.Errorf("error removing udev rules %s: %w", rule.path, err) + } + changed = true } + if changed { + reloadUdevRules(cmd) + } return nil } diff --git a/pkg/platforms/linux/installer/install_test.go b/pkg/platforms/linux/installer/install_test.go index aeb09bfcc..ce096bc56 100644 --- a/pkg/platforms/linux/installer/install_test.go +++ b/pkg/platforms/linux/installer/install_test.go @@ -316,6 +316,113 @@ func TestInstallDesktop(t *testing.T) { } } +func TestInstallUdevRules(t *testing.T) { + const ( + customOriginalRule = "# user-managed original rule\n" + customKillerRule = "# user-managed PN532Killer rule\n" + expectedKillerRule = "# Allow user access to PN532Killer USB UART readers\n" + + "SUBSYSTEMS==\"usb\", ATTRS{idVendor}==\"1a86\", ATTRS{idProduct}==\"55d3\", " + + "ATTRS{product}==\"PN532Killer-UART\", MODE=\"0660\", TAG+=\"uaccess\"\n" + ) + + tests := []struct { + setup func(*testing.T, []udevRuleFile) + name string + wantOriginalContent string + wantKillerContent string + wantReload bool + }{ + { + name: "fresh installation creates both files", + wantOriginalContent: udevFile, + wantKillerContent: expectedKillerRule, + wantReload: true, + }, + { + name: "upgrade preserves original and adds PN532Killer rule", + setup: func(t *testing.T, rules []udevRuleFile) { + t.Helper() + require.NoError(t, os.WriteFile(rules[0].path, []byte(customOriginalRule), 0o644)) + }, + wantOriginalContent: customOriginalRule, + wantKillerContent: expectedKillerRule, + wantReload: true, + }, + { + name: "reinstallation preserves existing files", + setup: func(t *testing.T, rules []udevRuleFile) { + t.Helper() + require.NoError(t, os.WriteFile(rules[0].path, []byte(customOriginalRule), 0o644)) + require.NoError(t, os.WriteFile(rules[1].path, []byte(customKillerRule), 0o644)) + }, + wantOriginalContent: customOriginalRule, + wantKillerContent: customKillerRule, + wantReload: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rules := testUdevRuleFiles(t) + if tt.setup != nil { + tt.setup(t, rules) + } + + cmd := &mocks.MockCommandExecutor{} + if tt.wantReload { + expectUdevReload(cmd) + } + + err := installUdevRules(cmd, rules) + require.NoError(t, err) + + originalContent, err := os.ReadFile(rules[0].path) //nolint:gosec // Test fixture path. + require.NoError(t, err) + assert.Equal(t, tt.wantOriginalContent, string(originalContent)) + killerContent, err := os.ReadFile(rules[1].path) //nolint:gosec // Test fixture path. + require.NoError(t, err) + assert.Equal(t, tt.wantKillerContent, string(killerContent)) + if !tt.wantReload { + assert.Empty(t, cmd.Calls) + } + cmd.AssertExpectations(t) + }) + } +} + +func TestUninstallUdevRules(t *testing.T) { + rules := testUdevRuleFiles(t) + for _, rule := range rules { + require.NoError(t, os.WriteFile(rule.path, []byte(rule.content), 0o644)) + } + + cmd := &mocks.MockCommandExecutor{} + expectUdevReload(cmd) + + err := uninstallUdevRules(cmd, rules) + require.NoError(t, err) + for _, rule := range rules { + _, statErr := os.Stat(rule.path) + assert.True(t, os.IsNotExist(statErr), "%s should be removed", rule.path) + } + cmd.AssertExpectations(t) +} + +func testUdevRuleFiles(t *testing.T) []udevRuleFile { + t.Helper() + + dir := t.TempDir() + return []udevRuleFile{ + {path: filepath.Join(dir, "60-zaparoo.rules"), content: udevFile}, + {path: filepath.Join(dir, "60-zaparoo-pn532killer.rules"), content: pn532KillerUdevFile}, + } +} + +func expectUdevReload(cmd *mocks.MockCommandExecutor) { + cmd.On("Run", mock.Anything, "udevadm", []string{"control", "--reload-rules"}).Return(nil).Once() + cmd.On("Run", mock.Anything, "udevadm", []string{"trigger"}).Return(nil).Once() +} + func TestUninstallApplication(t *testing.T) { // Cannot use t.Parallel() - tests modify shared XDG paths