Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
105 changes: 80 additions & 25 deletions pkg/platforms/linux/installer/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
107 changes: 107 additions & 0 deletions pkg/platforms/linux/installer/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down