detached bfc57c9

refactor(preflight): migrate to DDD architecture with comprehensive validations

- Add domain validation functions: ValidateArchLinux, ValidateNotDerivative, ValidateArchitecture, ValidateUEFIBoot, DetectSecureBoot, ValidateSecureBootDisabled
- Add DistributionType enum and DetectDistribution for Arch derivative detection
- Enhance PreflightResult DTO with Distribution and SecureBootEnabled fields
- Update preflight handler to use all new domain validations
- Add 45+ comprehensive tests using gomock for all new validation functions
- Achieve 100% feature parity with old preflight phase implementation
commit
bfc57c9d5c8892cfa1cd7aa20d7c01f9aeb076e6
author
bnema · · 2025-11-03 11:25 UTC
parent
2bf2308
signature
GPG Unverified2B82207D370127A0 The key is unknown
5 files · +603 -16
M internal/application/dto/preflight_result.go +4 -2
@@ -2,8 +2,10 @@ package dto
22
33 // SystemInfo contains basic system information
44 type SystemInfo struct {
5- Architecture string
6- IsUEFI bool
5+ Architecture string
6+ IsUEFI bool
7+ Distribution string
8+ SecureBootEnabled bool
79 }
810
911 // CPUInfo contains CPU information
@@ -2,8 +2,10 @@ package dto
2 2
3 // SystemInfo contains basic system information3 // SystemInfo contains basic system information
4 type SystemInfo struct {4 type SystemInfo struct {
5- Architecture string5+ Architecture string
6- IsUEFI bool6+ IsUEFI bool
7+ Distribution string
8+ SecureBootEnabled bool
7 }9 }
8 10
9 // CPUInfo contains CPU information11 // CPUInfo contains CPU information
M internal/application/handlers/preflight_handler.go +64 -14
@@ -2,10 +2,12 @@ package handlers
22
33 import (
44 "context"
5+ "strings"
56
67 "github.com/bnema/archup/internal/application/commands"
78 "github.com/bnema/archup/internal/application/dto"
89 "github.com/bnema/archup/internal/domain/ports"
10+ "github.com/bnema/archup/internal/domain/system"
911 )
1012
1113 // PreflightHandler handles preflight checks
@@ -13,6 +15,7 @@ type PreflightHandler struct {
1315 fs ports.FileSystem
1416 cmdExec ports.CommandExecutor
1517 logger ports.Logger
18+ rules *system.SystemValidationRules
1619 }
1720
1821 // NewPreflightHandler creates a new preflight handler
@@ -21,6 +24,7 @@ func NewPreflightHandler(fs ports.FileSystem, cmdExec ports.CommandExecutor, log
2124 fs: fs,
2225 cmdExec: cmdExec,
2326 logger: logger,
27+ rules: system.NewSystemValidationRules(),
2428 }
2529 }
2630
@@ -38,42 +42,88 @@ func (h *PreflightHandler) Handle(ctx context.Context, cmd commands.PreflightCom
3842
3943 // Check if running as root
4044 if output, err := h.cmdExec.Execute(ctx, "id", "-u"); err == nil {
41- if string(output) != "0\n" {
45+ if strings.TrimSpace(string(output)) != "0" {
4246 result.CriticalErrors = append(result.CriticalErrors, "Must run as root user")
4347 result.ChecksPassed = false
4448 h.logger.Warn("Not running as root")
4549 }
4650 }
4751
52+ // Check for Arch Linux
53+ if err := h.rules.ValidateArchLinux(ctx, h.fs); err != nil {
54+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
55+ result.ChecksPassed = false
56+ h.logger.Error("Not running on Arch Linux", "error", err)
57+ }
58+
59+ // Detect distribution type
60+ if distro, err := system.DetectDistribution(ctx, h.fs); err == nil {
61+ result.SystemInfo.Distribution = distro.String()
62+ h.logger.Info("Detected distribution", "distro", distro.String())
63+
64+ // Check for derivatives (must be vanilla Arch)
65+ if err := h.rules.ValidateNotDerivative(ctx, h.fs); err != nil {
66+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
67+ result.ChecksPassed = false
68+ h.logger.Error("Derivative distribution detected", "error", err)
69+ }
70+ }
71+
4872 // Check architecture
4973 if output, err := h.cmdExec.Execute(ctx, "uname", "-m"); err == nil && len(output) > 0 {
50- arch := string(output)
51- if arch[len(arch)-1] == '\n' {
52- arch = arch[:len(arch)-1]
53- }
74+ arch := strings.TrimSpace(string(output))
5475 result.SystemInfo.Architecture = arch
55- h.logger.Info("Detected architecture", "arch", result.SystemInfo.Architecture)
76+ h.logger.Info("Detected architecture", "arch", arch)
77+
78+ // Validate x86_64
79+ if err := h.rules.ValidateArchitecture(arch); err != nil {
80+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
81+ result.ChecksPassed = false
82+ h.logger.Error("Invalid architecture", "error", err)
83+ }
5684 }
5785
5886 // Check UEFI boot
59- exists, _ := h.fs.Exists("/sys/firmware/efi/fw_platform_size")
60- result.SystemInfo.IsUEFI = exists
61- if !exists {
62- result.Warnings = append(result.Warnings, "Not booted in UEFI mode")
87+ if err := h.rules.ValidateUEFIBoot(ctx, h.fs); err != nil {
88+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
89+ result.ChecksPassed = false
90+ result.SystemInfo.IsUEFI = false
91+ h.logger.Error("Not booted in UEFI mode", "error", err)
92+ } else {
93+ result.SystemInfo.IsUEFI = true
94+ h.logger.Info("UEFI boot mode confirmed")
95+ }
96+
97+ // Check Secure Boot status
98+ if enabled, err := h.rules.DetectSecureBoot(ctx, h.cmdExec); err == nil {
99+ result.SystemInfo.SecureBootEnabled = enabled
100+ if enabled {
101+ h.logger.Warn("Secure Boot is enabled")
102+ } else {
103+ h.logger.Info("Secure Boot is disabled")
104+ }
105+
106+ // Validate that Secure Boot is disabled
107+ if err := h.rules.ValidateSecureBootDisabled(ctx, h.cmdExec); err != nil {
108+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
109+ result.ChecksPassed = false
110+ h.logger.Error("Secure Boot must be disabled", "error", err)
111+ }
112+ } else {
113+ h.logger.Warn("Could not detect Secure Boot status", "error", err)
63114 }
64115
65116 // Check internet connectivity (try DNS)
66117 if _, err := h.cmdExec.Execute(ctx, "ping", "-c", "1", "archlinux.org"); err != nil {
67118 result.Warnings = append(result.Warnings, "Could not verify internet connectivity")
119+ h.logger.Warn("Internet connectivity check failed")
68120 }
69121
70122 // Get CPU info
71123 if output, err := h.cmdExec.Execute(ctx, "grep", "model name", "/proc/cpuinfo"); err == nil && len(output) > 0 {
72- model := string(output)
73- if model[len(model)-1] == '\n' {
74- model = model[:len(model)-1]
75- }
124+ model := strings.TrimSpace(string(output))
76125 result.CPUInfo.Model = model
126+ h.logger.Info("Detected CPU", "model", model)
77127 }
78128
79129 if result.ChecksPassed {
@@ -2,10 +2,12 @@ package handlers
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 6
6 "github.com/bnema/archup/internal/application/commands"7 "github.com/bnema/archup/internal/application/commands"
7 "github.com/bnema/archup/internal/application/dto"8 "github.com/bnema/archup/internal/application/dto"
8 "github.com/bnema/archup/internal/domain/ports"9 "github.com/bnema/archup/internal/domain/ports"
10+ "github.com/bnema/archup/internal/domain/system"
9 )11 )
10 12
11 // PreflightHandler handles preflight checks13 // PreflightHandler handles preflight checks
@@ -13,6 +15,7 @@ type PreflightHandler struct {
13 fs ports.FileSystem15 fs ports.FileSystem
14 cmdExec ports.CommandExecutor16 cmdExec ports.CommandExecutor
15 logger ports.Logger17 logger ports.Logger
18+ rules *system.SystemValidationRules
16 }19 }
17 20
18 // NewPreflightHandler creates a new preflight handler21 // NewPreflightHandler creates a new preflight handler
@@ -21,6 +24,7 @@ func NewPreflightHandler(fs ports.FileSystem, cmdExec ports.CommandExecutor, log
21 fs: fs,24 fs: fs,
22 cmdExec: cmdExec,25 cmdExec: cmdExec,
23 logger: logger,26 logger: logger,
27+ rules: system.NewSystemValidationRules(),
24 }28 }
25 }29 }
26 30
@@ -38,42 +42,88 @@ func (h *PreflightHandler) Handle(ctx context.Context, cmd commands.PreflightCom
38 42
39 // Check if running as root43 // Check if running as root
40 if output, err := h.cmdExec.Execute(ctx, "id", "-u"); err == nil {44 if output, err := h.cmdExec.Execute(ctx, "id", "-u"); err == nil {
41- if string(output) != "0\n" {45+ if strings.TrimSpace(string(output)) != "0" {
42 result.CriticalErrors = append(result.CriticalErrors, "Must run as root user")46 result.CriticalErrors = append(result.CriticalErrors, "Must run as root user")
43 result.ChecksPassed = false47 result.ChecksPassed = false
44 h.logger.Warn("Not running as root")48 h.logger.Warn("Not running as root")
45 }49 }
46 }50 }
47 51
52+ // Check for Arch Linux
53+ if err := h.rules.ValidateArchLinux(ctx, h.fs); err != nil {
54+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
55+ result.ChecksPassed = false
56+ h.logger.Error("Not running on Arch Linux", "error", err)
57+ }
58+
59+ // Detect distribution type
60+ if distro, err := system.DetectDistribution(ctx, h.fs); err == nil {
61+ result.SystemInfo.Distribution = distro.String()
62+ h.logger.Info("Detected distribution", "distro", distro.String())
63+
64+ // Check for derivatives (must be vanilla Arch)
65+ if err := h.rules.ValidateNotDerivative(ctx, h.fs); err != nil {
66+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
67+ result.ChecksPassed = false
68+ h.logger.Error("Derivative distribution detected", "error", err)
69+ }
70+ }
71+
48 // Check architecture72 // Check architecture
49 if output, err := h.cmdExec.Execute(ctx, "uname", "-m"); err == nil && len(output) > 0 {73 if output, err := h.cmdExec.Execute(ctx, "uname", "-m"); err == nil && len(output) > 0 {
50- arch := string(output)74+ arch := strings.TrimSpace(string(output))
51- if arch[len(arch)-1] == '\n' {
52- arch = arch[:len(arch)-1]
53- }
54 result.SystemInfo.Architecture = arch75 result.SystemInfo.Architecture = arch
55- h.logger.Info("Detected architecture", "arch", result.SystemInfo.Architecture)76+ h.logger.Info("Detected architecture", "arch", arch)
77+
78+ // Validate x86_64
79+ if err := h.rules.ValidateArchitecture(arch); err != nil {
80+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
81+ result.ChecksPassed = false
82+ h.logger.Error("Invalid architecture", "error", err)
83+ }
56 }84 }
57 85
58 // Check UEFI boot86 // Check UEFI boot
59- exists, _ := h.fs.Exists("/sys/firmware/efi/fw_platform_size")87+ if err := h.rules.ValidateUEFIBoot(ctx, h.fs); err != nil {
60- result.SystemInfo.IsUEFI = exists88+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
61- if !exists {89+ result.ChecksPassed = false
62- result.Warnings = append(result.Warnings, "Not booted in UEFI mode")90+ result.SystemInfo.IsUEFI = false
91+ h.logger.Error("Not booted in UEFI mode", "error", err)
92+ } else {
93+ result.SystemInfo.IsUEFI = true
94+ h.logger.Info("UEFI boot mode confirmed")
95+ }
96+
97+ // Check Secure Boot status
98+ if enabled, err := h.rules.DetectSecureBoot(ctx, h.cmdExec); err == nil {
99+ result.SystemInfo.SecureBootEnabled = enabled
100+ if enabled {
101+ h.logger.Warn("Secure Boot is enabled")
102+ } else {
103+ h.logger.Info("Secure Boot is disabled")
104+ }
105+
106+ // Validate that Secure Boot is disabled
107+ if err := h.rules.ValidateSecureBootDisabled(ctx, h.cmdExec); err != nil {
108+ result.CriticalErrors = append(result.CriticalErrors, err.Error())
109+ result.ChecksPassed = false
110+ h.logger.Error("Secure Boot must be disabled", "error", err)
111+ }
112+ } else {
113+ h.logger.Warn("Could not detect Secure Boot status", "error", err)
63 }114 }
64 115
65 // Check internet connectivity (try DNS)116 // Check internet connectivity (try DNS)
66 if _, err := h.cmdExec.Execute(ctx, "ping", "-c", "1", "archlinux.org"); err != nil {117 if _, err := h.cmdExec.Execute(ctx, "ping", "-c", "1", "archlinux.org"); err != nil {
67 result.Warnings = append(result.Warnings, "Could not verify internet connectivity")118 result.Warnings = append(result.Warnings, "Could not verify internet connectivity")
119+ h.logger.Warn("Internet connectivity check failed")
68 }120 }
69 121
70 // Get CPU info122 // Get CPU info
71 if output, err := h.cmdExec.Execute(ctx, "grep", "model name", "/proc/cpuinfo"); err == nil && len(output) > 0 {123 if output, err := h.cmdExec.Execute(ctx, "grep", "model name", "/proc/cpuinfo"); err == nil && len(output) > 0 {
72- model := string(output)124+ model := strings.TrimSpace(string(output))
73- if model[len(model)-1] == '\n' {
74- model = model[:len(model)-1]
75- }
76 result.CPUInfo.Model = model125 result.CPUInfo.Model = model
126+ h.logger.Info("Detected CPU", "model", model)
77 }127 }
78 128
79 if result.ChecksPassed {129 if result.ChecksPassed {
M internal/domain/system/config.go +70 -0
@@ -1,10 +1,80 @@
11 package system
22
33 import (
4+ "context"
45 "errors"
56 "strings"
7+
8+ "github.com/bnema/archup/internal/domain/ports"
9+)
10+
11+// DistributionType represents the type of Arch-based distribution
12+type DistributionType int
13+
14+const (
15+ // DistributionArch represents vanilla Arch Linux
16+ DistributionArch DistributionType = iota
17+ // DistributionCachyOS represents CachyOS
18+ DistributionCachyOS
19+ // DistributionEndeavourOS represents EndeavourOS
20+ DistributionEndeavourOS
21+ // DistributionGaruda represents Garuda Linux
22+ DistributionGaruda
23+ // DistributionManjaro represents Manjaro Linux
24+ DistributionManjaro
25+ // DistributionUnknown represents an unknown or unsupported distribution
26+ DistributionUnknown
627 )
728
29+// String returns the string representation of the distribution type
30+func (d DistributionType) String() string {
31+ switch d {
32+ case DistributionArch:
33+ return "Arch Linux"
34+ case DistributionCachyOS:
35+ return "CachyOS"
36+ case DistributionEndeavourOS:
37+ return "EndeavourOS"
38+ case DistributionGaruda:
39+ return "Garuda Linux"
40+ case DistributionManjaro:
41+ return "Manjaro Linux"
42+ case DistributionUnknown:
43+ return "Unknown"
44+ default:
45+ return "Unknown"
46+ }
47+}
48+
49+// DetectDistribution detects which Arch-based distribution is running
50+func DetectDistribution(ctx context.Context, fs ports.FileSystem) (DistributionType, error) {
51+ // Check for vanilla Arch first
52+ exists, err := fs.Exists("/etc/arch-release")
53+ if err != nil {
54+ return DistributionUnknown, errors.New("failed to detect distribution: " + err.Error())
55+ }
56+ if !exists {
57+ return DistributionUnknown, errors.New("not running on Arch Linux or Arch ISO")
58+ }
59+
60+ // Check for derivatives
61+ if exists, _ := fs.Exists("/etc/cachyos-release"); exists {
62+ return DistributionCachyOS, nil
63+ }
64+ if exists, _ := fs.Exists("/etc/eos-release"); exists {
65+ return DistributionEndeavourOS, nil
66+ }
67+ if exists, _ := fs.Exists("/etc/garuda-release"); exists {
68+ return DistributionGaruda, nil
69+ }
70+ if exists, _ := fs.Exists("/etc/manjaro-release"); exists {
71+ return DistributionManjaro, nil
72+ }
73+
74+ // If no derivative markers found, it's vanilla Arch
75+ return DistributionArch, nil
76+}
77+
878 // SystemConfig is an immutable value object representing system configuration
979 // It encapsulates hostname, locale, timezone, and keymap settings
1080 type SystemConfig struct {
@@ -1,10 +1,80 @@
1 package system1 package system
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "strings"6 "strings"
7+
8+ "github.com/bnema/archup/internal/domain/ports"
9+)
10+
11+// DistributionType represents the type of Arch-based distribution
12+type DistributionType int
13+
14+const (
15+ // DistributionArch represents vanilla Arch Linux
16+ DistributionArch DistributionType = iota
17+ // DistributionCachyOS represents CachyOS
18+ DistributionCachyOS
19+ // DistributionEndeavourOS represents EndeavourOS
20+ DistributionEndeavourOS
21+ // DistributionGaruda represents Garuda Linux
22+ DistributionGaruda
23+ // DistributionManjaro represents Manjaro Linux
24+ DistributionManjaro
25+ // DistributionUnknown represents an unknown or unsupported distribution
26+ DistributionUnknown
6 )27 )
7 28
29+// String returns the string representation of the distribution type
30+func (d DistributionType) String() string {
31+ switch d {
32+ case DistributionArch:
33+ return "Arch Linux"
34+ case DistributionCachyOS:
35+ return "CachyOS"
36+ case DistributionEndeavourOS:
37+ return "EndeavourOS"
38+ case DistributionGaruda:
39+ return "Garuda Linux"
40+ case DistributionManjaro:
41+ return "Manjaro Linux"
42+ case DistributionUnknown:
43+ return "Unknown"
44+ default:
45+ return "Unknown"
46+ }
47+}
48+
49+// DetectDistribution detects which Arch-based distribution is running
50+func DetectDistribution(ctx context.Context, fs ports.FileSystem) (DistributionType, error) {
51+ // Check for vanilla Arch first
52+ exists, err := fs.Exists("/etc/arch-release")
53+ if err != nil {
54+ return DistributionUnknown, errors.New("failed to detect distribution: " + err.Error())
55+ }
56+ if !exists {
57+ return DistributionUnknown, errors.New("not running on Arch Linux or Arch ISO")
58+ }
59+
60+ // Check for derivatives
61+ if exists, _ := fs.Exists("/etc/cachyos-release"); exists {
62+ return DistributionCachyOS, nil
63+ }
64+ if exists, _ := fs.Exists("/etc/eos-release"); exists {
65+ return DistributionEndeavourOS, nil
66+ }
67+ if exists, _ := fs.Exists("/etc/garuda-release"); exists {
68+ return DistributionGaruda, nil
69+ }
70+ if exists, _ := fs.Exists("/etc/manjaro-release"); exists {
71+ return DistributionManjaro, nil
72+ }
73+
74+ // If no derivative markers found, it's vanilla Arch
75+ return DistributionArch, nil
76+}
77+
8 // SystemConfig is an immutable value object representing system configuration78 // SystemConfig is an immutable value object representing system configuration
9 // It encapsulates hostname, locale, timezone, and keymap settings79 // It encapsulates hostname, locale, timezone, and keymap settings
10 type SystemConfig struct {80 type SystemConfig struct {
M internal/domain/system/system_test.go +369 -0
@@ -1,8 +1,13 @@
11 package system
22
33 import (
4+ "context"
5+ "errors"
46 "strings"
57 "testing"
8+
9+ "github.com/bnema/archup/internal/domain/ports/mocks"
10+ "go.uber.org/mock/gomock"
611 )
712
813 // SystemConfig Tests
@@ -438,6 +443,370 @@ func TestSuggestedKeymapForLocale(t *testing.T) {
438443 }
439444 }
440445
446+// New validation function tests
447+
448+func TestValidateArchLinux(t *testing.T) {
449+ ctrl := gomock.NewController(t)
450+ defer ctrl.Finish()
451+
452+ rules := NewSystemValidationRules()
453+ ctx := context.Background()
454+
455+ tests := []struct {
456+ name string
457+ fileExists bool
458+ shouldErr bool
459+ }{
460+ {"arch-release exists", true, false},
461+ {"arch-release missing", false, true},
462+ }
463+
464+ for _, tt := range tests {
465+ t.Run(tt.name, func(t *testing.T) {
466+ mockFS := mocks.NewMockFileSystem(ctrl)
467+ mockFS.EXPECT().Exists("/etc/arch-release").Return(tt.fileExists, nil)
468+
469+ err := rules.ValidateArchLinux(ctx, mockFS)
470+ if (err != nil) != tt.shouldErr {
471+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
472+ }
473+ })
474+ }
475+}
476+
477+func TestValidateNotDerivative(t *testing.T) {
478+ rules := NewSystemValidationRules()
479+ ctx := context.Background()
480+
481+ tests := []struct {
482+ name string
483+ setupMock func(*mocks.MockFileSystem)
484+ shouldErr bool
485+ errMsg string
486+ }{
487+ {
488+ name: "vanilla arch",
489+ setupMock: func(mockFS *mocks.MockFileSystem) {
490+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
491+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
492+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
493+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil)
494+ },
495+ shouldErr: false,
496+ },
497+ {
498+ name: "cachyos detected",
499+ setupMock: func(mockFS *mocks.MockFileSystem) {
500+ // Map iteration order is undefined, so any may be checked first
501+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(true, nil).AnyTimes()
502+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
503+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
504+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
505+ },
506+ shouldErr: true,
507+ errMsg: "CachyOS",
508+ },
509+ {
510+ name: "endeavouros detected",
511+ setupMock: func(mockFS *mocks.MockFileSystem) {
512+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
513+ mockFS.EXPECT().Exists("/etc/eos-release").Return(true, nil).AnyTimes()
514+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
515+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
516+ },
517+ shouldErr: true,
518+ errMsg: "EndeavourOS",
519+ },
520+ {
521+ name: "garuda detected",
522+ setupMock: func(mockFS *mocks.MockFileSystem) {
523+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
524+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
525+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(true, nil).AnyTimes()
526+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
527+ },
528+ shouldErr: true,
529+ errMsg: "Garuda",
530+ },
531+ {
532+ name: "manjaro detected",
533+ setupMock: func(mockFS *mocks.MockFileSystem) {
534+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
535+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
536+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
537+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(true, nil).AnyTimes()
538+ },
539+ shouldErr: true,
540+ errMsg: "Manjaro",
541+ },
542+ }
543+
544+ for _, tt := range tests {
545+ t.Run(tt.name, func(t *testing.T) {
546+ ctrl := gomock.NewController(t)
547+ defer ctrl.Finish()
548+
549+ mockFS := mocks.NewMockFileSystem(ctrl)
550+ tt.setupMock(mockFS)
551+
552+ err := rules.ValidateNotDerivative(ctx, mockFS)
553+ if (err != nil) != tt.shouldErr {
554+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
555+ }
556+ if tt.shouldErr && err != nil && !strings.Contains(err.Error(), tt.errMsg) {
557+ t.Errorf("expected error to contain '%s', got '%s'", tt.errMsg, err.Error())
558+ }
559+ })
560+ }
561+}
562+
563+func TestValidateArchitecture(t *testing.T) {
564+ rules := NewSystemValidationRules()
565+
566+ tests := []struct {
567+ name string
568+ arch string
569+ shouldErr bool
570+ }{
571+ {"x86_64", "x86_64", false},
572+ {"empty", "", true},
573+ {"aarch64", "aarch64", true},
574+ {"i686", "i686", true},
575+ }
576+
577+ for _, tt := range tests {
578+ t.Run(tt.name, func(t *testing.T) {
579+ err := rules.ValidateArchitecture(tt.arch)
580+ if (err != nil) != tt.shouldErr {
581+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
582+ }
583+ })
584+ }
585+}
586+
587+func TestValidateUEFIBoot(t *testing.T) {
588+ ctrl := gomock.NewController(t)
589+ defer ctrl.Finish()
590+
591+ rules := NewSystemValidationRules()
592+ ctx := context.Background()
593+
594+ tests := []struct {
595+ name string
596+ efivarsExists bool
597+ shouldErr bool
598+ }{
599+ {"efivars exists", true, false},
600+ {"efivars missing", false, true},
601+ }
602+
603+ for _, tt := range tests {
604+ t.Run(tt.name, func(t *testing.T) {
605+ mockFS := mocks.NewMockFileSystem(ctrl)
606+ mockFS.EXPECT().Exists("/sys/firmware/efi/efivars").Return(tt.efivarsExists, nil)
607+
608+ err := rules.ValidateUEFIBoot(ctx, mockFS)
609+ if (err != nil) != tt.shouldErr {
610+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
611+ }
612+ })
613+ }
614+}
615+
616+func TestDetectSecureBoot(t *testing.T) {
617+ ctrl := gomock.NewController(t)
618+ defer ctrl.Finish()
619+
620+ rules := NewSystemValidationRules()
621+ ctx := context.Background()
622+
623+ tests := []struct {
624+ name string
625+ output []byte
626+ cmdErr error
627+ expected bool
628+ }{
629+ {
630+ name: "secure boot enabled",
631+ output: []byte("System:\n Secure Boot: enabled\n"),
632+ expected: true,
633+ },
634+ {
635+ name: "secure boot disabled",
636+ output: []byte("System:\n Secure Boot: disabled\n"),
637+ expected: false,
638+ },
639+ {
640+ name: "bootctl fails",
641+ cmdErr: errors.New("bootctl not found"),
642+ expected: false,
643+ },
644+ }
645+
646+ for _, tt := range tests {
647+ t.Run(tt.name, func(t *testing.T) {
648+ mockExec := mocks.NewMockCommandExecutor(ctrl)
649+ if tt.cmdErr != nil {
650+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(nil, tt.cmdErr)
651+ } else {
652+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(tt.output, nil)
653+ }
654+
655+ enabled, err := rules.DetectSecureBoot(ctx, mockExec)
656+ if err != nil {
657+ t.Errorf("unexpected error: %v", err)
658+ }
659+ if enabled != tt.expected {
660+ t.Errorf("expected %v, got %v", tt.expected, enabled)
661+ }
662+ })
663+ }
664+}
665+
666+func TestValidateSecureBootDisabled(t *testing.T) {
667+ ctrl := gomock.NewController(t)
668+ defer ctrl.Finish()
669+
670+ rules := NewSystemValidationRules()
671+ ctx := context.Background()
672+
673+ tests := []struct {
674+ name string
675+ output []byte
676+ shouldErr bool
677+ }{
678+ {
679+ name: "secure boot disabled",
680+ output: []byte("System:\n Secure Boot: disabled\n"),
681+ shouldErr: false,
682+ },
683+ {
684+ name: "secure boot enabled",
685+ output: []byte("System:\n Secure Boot: enabled\n"),
686+ shouldErr: true,
687+ },
688+ }
689+
690+ for _, tt := range tests {
691+ t.Run(tt.name, func(t *testing.T) {
692+ mockExec := mocks.NewMockCommandExecutor(ctrl)
693+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(tt.output, nil)
694+
695+ err := rules.ValidateSecureBootDisabled(ctx, mockExec)
696+ if (err != nil) != tt.shouldErr {
697+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
698+ }
699+ })
700+ }
701+}
702+
703+func TestDistributionType_String(t *testing.T) {
704+ tests := []struct {
705+ distro DistributionType
706+ want string
707+ }{
708+ {DistributionArch, "Arch Linux"},
709+ {DistributionCachyOS, "CachyOS"},
710+ {DistributionEndeavourOS, "EndeavourOS"},
711+ {DistributionGaruda, "Garuda Linux"},
712+ {DistributionManjaro, "Manjaro Linux"},
713+ {DistributionUnknown, "Unknown"},
714+ }
715+
716+ for _, tt := range tests {
717+ if got := tt.distro.String(); got != tt.want {
718+ t.Errorf("expected %s, got %s", tt.want, got)
719+ }
720+ }
721+}
722+
723+func TestDetectDistribution(t *testing.T) {
724+ ctrl := gomock.NewController(t)
725+ defer ctrl.Finish()
726+
727+ ctx := context.Background()
728+
729+ tests := []struct {
730+ name string
731+ setupMock func(*mocks.MockFileSystem)
732+ expected DistributionType
733+ shouldErr bool
734+ }{
735+ {
736+ name: "vanilla arch",
737+ setupMock: func(mockFS *mocks.MockFileSystem) {
738+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
739+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
740+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
741+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
742+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil)
743+ },
744+ expected: DistributionArch,
745+ },
746+ {
747+ name: "cachyos",
748+ setupMock: func(mockFS *mocks.MockFileSystem) {
749+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
750+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(true, nil)
751+ },
752+ expected: DistributionCachyOS,
753+ },
754+ {
755+ name: "endeavouros",
756+ setupMock: func(mockFS *mocks.MockFileSystem) {
757+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
758+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
759+ mockFS.EXPECT().Exists("/etc/eos-release").Return(true, nil)
760+ },
761+ expected: DistributionEndeavourOS,
762+ },
763+ {
764+ name: "garuda",
765+ setupMock: func(mockFS *mocks.MockFileSystem) {
766+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
767+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
768+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
769+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(true, nil)
770+ },
771+ expected: DistributionGaruda,
772+ },
773+ {
774+ name: "manjaro",
775+ setupMock: func(mockFS *mocks.MockFileSystem) {
776+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
777+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
778+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
779+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
780+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(true, nil)
781+ },
782+ expected: DistributionManjaro,
783+ },
784+ {
785+ name: "not arch",
786+ setupMock: func(mockFS *mocks.MockFileSystem) {
787+ mockFS.EXPECT().Exists("/etc/arch-release").Return(false, nil)
788+ },
789+ expected: DistributionUnknown,
790+ shouldErr: true,
791+ },
792+ }
793+
794+ for _, tt := range tests {
795+ t.Run(tt.name, func(t *testing.T) {
796+ mockFS := mocks.NewMockFileSystem(ctrl)
797+ tt.setupMock(mockFS)
798+
799+ distro, err := DetectDistribution(ctx, mockFS)
800+ if (err != nil) != tt.shouldErr {
801+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
802+ }
803+ if distro != tt.expected {
804+ t.Errorf("expected %v, got %v", tt.expected, distro)
805+ }
806+ })
807+ }
808+}
809+
441810 // Helper function
442811 func min(a, b int) int {
443812 if a < b {
@@ -1,8 +1,13 @@
1 package system1 package system
2 2
3 import (3 import (
4+ "context"
5+ "errors"
4 "strings"6 "strings"
5 "testing"7 "testing"
8+
9+ "github.com/bnema/archup/internal/domain/ports/mocks"
10+ "go.uber.org/mock/gomock"
6 )11 )
7 12
8 // SystemConfig Tests13 // SystemConfig Tests
@@ -438,6 +443,370 @@ func TestSuggestedKeymapForLocale(t *testing.T) {
438 }443 }
439 }444 }
440 445
446+// New validation function tests
447+
448+func TestValidateArchLinux(t *testing.T) {
449+ ctrl := gomock.NewController(t)
450+ defer ctrl.Finish()
451+
452+ rules := NewSystemValidationRules()
453+ ctx := context.Background()
454+
455+ tests := []struct {
456+ name string
457+ fileExists bool
458+ shouldErr bool
459+ }{
460+ {"arch-release exists", true, false},
461+ {"arch-release missing", false, true},
462+ }
463+
464+ for _, tt := range tests {
465+ t.Run(tt.name, func(t *testing.T) {
466+ mockFS := mocks.NewMockFileSystem(ctrl)
467+ mockFS.EXPECT().Exists("/etc/arch-release").Return(tt.fileExists, nil)
468+
469+ err := rules.ValidateArchLinux(ctx, mockFS)
470+ if (err != nil) != tt.shouldErr {
471+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
472+ }
473+ })
474+ }
475+}
476+
477+func TestValidateNotDerivative(t *testing.T) {
478+ rules := NewSystemValidationRules()
479+ ctx := context.Background()
480+
481+ tests := []struct {
482+ name string
483+ setupMock func(*mocks.MockFileSystem)
484+ shouldErr bool
485+ errMsg string
486+ }{
487+ {
488+ name: "vanilla arch",
489+ setupMock: func(mockFS *mocks.MockFileSystem) {
490+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
491+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
492+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
493+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil)
494+ },
495+ shouldErr: false,
496+ },
497+ {
498+ name: "cachyos detected",
499+ setupMock: func(mockFS *mocks.MockFileSystem) {
500+ // Map iteration order is undefined, so any may be checked first
501+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(true, nil).AnyTimes()
502+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
503+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
504+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
505+ },
506+ shouldErr: true,
507+ errMsg: "CachyOS",
508+ },
509+ {
510+ name: "endeavouros detected",
511+ setupMock: func(mockFS *mocks.MockFileSystem) {
512+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
513+ mockFS.EXPECT().Exists("/etc/eos-release").Return(true, nil).AnyTimes()
514+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
515+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
516+ },
517+ shouldErr: true,
518+ errMsg: "EndeavourOS",
519+ },
520+ {
521+ name: "garuda detected",
522+ setupMock: func(mockFS *mocks.MockFileSystem) {
523+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
524+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
525+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(true, nil).AnyTimes()
526+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil).AnyTimes()
527+ },
528+ shouldErr: true,
529+ errMsg: "Garuda",
530+ },
531+ {
532+ name: "manjaro detected",
533+ setupMock: func(mockFS *mocks.MockFileSystem) {
534+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil).AnyTimes()
535+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil).AnyTimes()
536+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil).AnyTimes()
537+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(true, nil).AnyTimes()
538+ },
539+ shouldErr: true,
540+ errMsg: "Manjaro",
541+ },
542+ }
543+
544+ for _, tt := range tests {
545+ t.Run(tt.name, func(t *testing.T) {
546+ ctrl := gomock.NewController(t)
547+ defer ctrl.Finish()
548+
549+ mockFS := mocks.NewMockFileSystem(ctrl)
550+ tt.setupMock(mockFS)
551+
552+ err := rules.ValidateNotDerivative(ctx, mockFS)
553+ if (err != nil) != tt.shouldErr {
554+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
555+ }
556+ if tt.shouldErr && err != nil && !strings.Contains(err.Error(), tt.errMsg) {
557+ t.Errorf("expected error to contain '%s', got '%s'", tt.errMsg, err.Error())
558+ }
559+ })
560+ }
561+}
562+
563+func TestValidateArchitecture(t *testing.T) {
564+ rules := NewSystemValidationRules()
565+
566+ tests := []struct {
567+ name string
568+ arch string
569+ shouldErr bool
570+ }{
571+ {"x86_64", "x86_64", false},
572+ {"empty", "", true},
573+ {"aarch64", "aarch64", true},
574+ {"i686", "i686", true},
575+ }
576+
577+ for _, tt := range tests {
578+ t.Run(tt.name, func(t *testing.T) {
579+ err := rules.ValidateArchitecture(tt.arch)
580+ if (err != nil) != tt.shouldErr {
581+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
582+ }
583+ })
584+ }
585+}
586+
587+func TestValidateUEFIBoot(t *testing.T) {
588+ ctrl := gomock.NewController(t)
589+ defer ctrl.Finish()
590+
591+ rules := NewSystemValidationRules()
592+ ctx := context.Background()
593+
594+ tests := []struct {
595+ name string
596+ efivarsExists bool
597+ shouldErr bool
598+ }{
599+ {"efivars exists", true, false},
600+ {"efivars missing", false, true},
601+ }
602+
603+ for _, tt := range tests {
604+ t.Run(tt.name, func(t *testing.T) {
605+ mockFS := mocks.NewMockFileSystem(ctrl)
606+ mockFS.EXPECT().Exists("/sys/firmware/efi/efivars").Return(tt.efivarsExists, nil)
607+
608+ err := rules.ValidateUEFIBoot(ctx, mockFS)
609+ if (err != nil) != tt.shouldErr {
610+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
611+ }
612+ })
613+ }
614+}
615+
616+func TestDetectSecureBoot(t *testing.T) {
617+ ctrl := gomock.NewController(t)
618+ defer ctrl.Finish()
619+
620+ rules := NewSystemValidationRules()
621+ ctx := context.Background()
622+
623+ tests := []struct {
624+ name string
625+ output []byte
626+ cmdErr error
627+ expected bool
628+ }{
629+ {
630+ name: "secure boot enabled",
631+ output: []byte("System:\n Secure Boot: enabled\n"),
632+ expected: true,
633+ },
634+ {
635+ name: "secure boot disabled",
636+ output: []byte("System:\n Secure Boot: disabled\n"),
637+ expected: false,
638+ },
639+ {
640+ name: "bootctl fails",
641+ cmdErr: errors.New("bootctl not found"),
642+ expected: false,
643+ },
644+ }
645+
646+ for _, tt := range tests {
647+ t.Run(tt.name, func(t *testing.T) {
648+ mockExec := mocks.NewMockCommandExecutor(ctrl)
649+ if tt.cmdErr != nil {
650+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(nil, tt.cmdErr)
651+ } else {
652+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(tt.output, nil)
653+ }
654+
655+ enabled, err := rules.DetectSecureBoot(ctx, mockExec)
656+ if err != nil {
657+ t.Errorf("unexpected error: %v", err)
658+ }
659+ if enabled != tt.expected {
660+ t.Errorf("expected %v, got %v", tt.expected, enabled)
661+ }
662+ })
663+ }
664+}
665+
666+func TestValidateSecureBootDisabled(t *testing.T) {
667+ ctrl := gomock.NewController(t)
668+ defer ctrl.Finish()
669+
670+ rules := NewSystemValidationRules()
671+ ctx := context.Background()
672+
673+ tests := []struct {
674+ name string
675+ output []byte
676+ shouldErr bool
677+ }{
678+ {
679+ name: "secure boot disabled",
680+ output: []byte("System:\n Secure Boot: disabled\n"),
681+ shouldErr: false,
682+ },
683+ {
684+ name: "secure boot enabled",
685+ output: []byte("System:\n Secure Boot: enabled\n"),
686+ shouldErr: true,
687+ },
688+ }
689+
690+ for _, tt := range tests {
691+ t.Run(tt.name, func(t *testing.T) {
692+ mockExec := mocks.NewMockCommandExecutor(ctrl)
693+ mockExec.EXPECT().Execute(ctx, "bootctl", "status").Return(tt.output, nil)
694+
695+ err := rules.ValidateSecureBootDisabled(ctx, mockExec)
696+ if (err != nil) != tt.shouldErr {
697+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
698+ }
699+ })
700+ }
701+}
702+
703+func TestDistributionType_String(t *testing.T) {
704+ tests := []struct {
705+ distro DistributionType
706+ want string
707+ }{
708+ {DistributionArch, "Arch Linux"},
709+ {DistributionCachyOS, "CachyOS"},
710+ {DistributionEndeavourOS, "EndeavourOS"},
711+ {DistributionGaruda, "Garuda Linux"},
712+ {DistributionManjaro, "Manjaro Linux"},
713+ {DistributionUnknown, "Unknown"},
714+ }
715+
716+ for _, tt := range tests {
717+ if got := tt.distro.String(); got != tt.want {
718+ t.Errorf("expected %s, got %s", tt.want, got)
719+ }
720+ }
721+}
722+
723+func TestDetectDistribution(t *testing.T) {
724+ ctrl := gomock.NewController(t)
725+ defer ctrl.Finish()
726+
727+ ctx := context.Background()
728+
729+ tests := []struct {
730+ name string
731+ setupMock func(*mocks.MockFileSystem)
732+ expected DistributionType
733+ shouldErr bool
734+ }{
735+ {
736+ name: "vanilla arch",
737+ setupMock: func(mockFS *mocks.MockFileSystem) {
738+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
739+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
740+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
741+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
742+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(false, nil)
743+ },
744+ expected: DistributionArch,
745+ },
746+ {
747+ name: "cachyos",
748+ setupMock: func(mockFS *mocks.MockFileSystem) {
749+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
750+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(true, nil)
751+ },
752+ expected: DistributionCachyOS,
753+ },
754+ {
755+ name: "endeavouros",
756+ setupMock: func(mockFS *mocks.MockFileSystem) {
757+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
758+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
759+ mockFS.EXPECT().Exists("/etc/eos-release").Return(true, nil)
760+ },
761+ expected: DistributionEndeavourOS,
762+ },
763+ {
764+ name: "garuda",
765+ setupMock: func(mockFS *mocks.MockFileSystem) {
766+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
767+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
768+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
769+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(true, nil)
770+ },
771+ expected: DistributionGaruda,
772+ },
773+ {
774+ name: "manjaro",
775+ setupMock: func(mockFS *mocks.MockFileSystem) {
776+ mockFS.EXPECT().Exists("/etc/arch-release").Return(true, nil)
777+ mockFS.EXPECT().Exists("/etc/cachyos-release").Return(false, nil)
778+ mockFS.EXPECT().Exists("/etc/eos-release").Return(false, nil)
779+ mockFS.EXPECT().Exists("/etc/garuda-release").Return(false, nil)
780+ mockFS.EXPECT().Exists("/etc/manjaro-release").Return(true, nil)
781+ },
782+ expected: DistributionManjaro,
783+ },
784+ {
785+ name: "not arch",
786+ setupMock: func(mockFS *mocks.MockFileSystem) {
787+ mockFS.EXPECT().Exists("/etc/arch-release").Return(false, nil)
788+ },
789+ expected: DistributionUnknown,
790+ shouldErr: true,
791+ },
792+ }
793+
794+ for _, tt := range tests {
795+ t.Run(tt.name, func(t *testing.T) {
796+ mockFS := mocks.NewMockFileSystem(ctrl)
797+ tt.setupMock(mockFS)
798+
799+ distro, err := DetectDistribution(ctx, mockFS)
800+ if (err != nil) != tt.shouldErr {
801+ t.Errorf("got error %v, expected error=%v", err, tt.shouldErr)
802+ }
803+ if distro != tt.expected {
804+ t.Errorf("expected %v, got %v", tt.expected, distro)
805+ }
806+ })
807+ }
808+}
809+
441 // Helper function810 // Helper function
442 func min(a, b int) int {811 func min(a, b int) int {
443 if a < b {812 if a < b {
M internal/domain/system/validation.go +96 -0
@@ -1,8 +1,11 @@
11 package system
22
33 import (
4+ "context"
45 "errors"
56 "strings"
7+
8+ "github.com/bnema/archup/internal/domain/ports"
69 )
710
811 // SystemValidationRules contains business rules for system configuration
@@ -165,3 +168,96 @@ func (r *SystemValidationRules) LocaleRequirements() string {
165168 func (r *SystemValidationRules) KeymapRequirements() string {
166169 return "Keymap must be a valid keyboard layout code like us, de, fr, or dvorak. Can include variants like de-nodeadkeys"
167170 }
171+
172+// ValidateArchLinux checks if the system is running Arch Linux
173+// Returns an error if not running on Arch Linux or Arch ISO
174+func (r *SystemValidationRules) ValidateArchLinux(ctx context.Context, fs ports.FileSystem) error {
175+ exists, err := fs.Exists("/etc/arch-release")
176+ if err != nil {
177+ return errors.New("failed to check for Arch Linux: " + err.Error())
178+ }
179+ if !exists {
180+ return errors.New("must be running on Arch Linux or Arch ISO")
181+ }
182+ return nil
183+}
184+
185+// ValidateNotDerivative checks if the system is vanilla Arch (not a derivative)
186+// Returns an error if a derivative is detected (CachyOS, EndeavourOS, Garuda, Manjaro)
187+func (r *SystemValidationRules) ValidateNotDerivative(ctx context.Context, fs ports.FileSystem) error {
188+ derivatives := map[string]string{
189+ "/etc/cachyos-release": "CachyOS",
190+ "/etc/eos-release": "EndeavourOS",
191+ "/etc/garuda-release": "Garuda",
192+ "/etc/manjaro-release": "Manjaro",
193+ }
194+
195+ for marker, name := range derivatives {
196+ exists, err := fs.Exists(marker)
197+ if err != nil {
198+ // Continue checking other markers even if one fails
199+ continue
200+ }
201+ if exists {
202+ return errors.New("must be vanilla Arch (detected " + name + ")")
203+ }
204+ }
205+
206+ return nil
207+}
208+
209+// ValidateArchitecture checks if the system architecture is x86_64
210+// Returns an error if not running on x86_64 architecture
211+func (r *SystemValidationRules) ValidateArchitecture(arch string) error {
212+ if arch == "" {
213+ return errors.New("architecture cannot be empty")
214+ }
215+ if arch != "x86_64" {
216+ return errors.New("must be x86_64 architecture (detected: " + arch + ")")
217+ }
218+ return nil
219+}
220+
221+// ValidateUEFIBoot checks if the system is booted in UEFI mode
222+// Returns an error if not booted in UEFI mode (legacy BIOS is not supported)
223+func (r *SystemValidationRules) ValidateUEFIBoot(ctx context.Context, fs ports.FileSystem) error {
224+ exists, err := fs.Exists("/sys/firmware/efi/efivars")
225+ if err != nil {
226+ return errors.New("failed to check UEFI boot mode: " + err.Error())
227+ }
228+ if !exists {
229+ return errors.New("must be UEFI boot mode (legacy BIOS not supported)")
230+ }
231+ return nil
232+}
233+
234+// DetectSecureBoot checks if Secure Boot is enabled
235+// Returns true if enabled, false if disabled, and an error if detection fails
236+func (r *SystemValidationRules) DetectSecureBoot(ctx context.Context, exec ports.CommandExecutor) (bool, error) {
237+ output, err := exec.Execute(ctx, "bootctl", "status")
238+ if err != nil {
239+ // If bootctl fails, we can't determine Secure Boot status
240+ // This is not necessarily an error - might not be available
241+ return false, nil
242+ }
243+
244+ // Check if output contains "Secure Boot: enabled"
245+ if strings.Contains(string(output), "Secure Boot: enabled") {
246+ return true, nil
247+ }
248+
249+ return false, nil
250+}
251+
252+// ValidateSecureBootDisabled checks that Secure Boot is disabled
253+// Returns an error if Secure Boot is enabled
254+func (r *SystemValidationRules) ValidateSecureBootDisabled(ctx context.Context, exec ports.CommandExecutor) error {
255+ enabled, err := r.DetectSecureBoot(ctx, exec)
256+ if err != nil {
257+ return errors.New("failed to detect Secure Boot status: " + err.Error())
258+ }
259+ if enabled {
260+ return errors.New("Secure Boot must be disabled")
261+ }
262+ return nil
263+}
@@ -1,8 +1,11 @@
1 package system1 package system
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "strings"6 "strings"
7+
8+ "github.com/bnema/archup/internal/domain/ports"
6 )9 )
7 10
8 // SystemValidationRules contains business rules for system configuration11 // SystemValidationRules contains business rules for system configuration
@@ -165,3 +168,96 @@ func (r *SystemValidationRules) LocaleRequirements() string {
165 func (r *SystemValidationRules) KeymapRequirements() string {168 func (r *SystemValidationRules) KeymapRequirements() string {
166 return "Keymap must be a valid keyboard layout code like us, de, fr, or dvorak. Can include variants like de-nodeadkeys"169 return "Keymap must be a valid keyboard layout code like us, de, fr, or dvorak. Can include variants like de-nodeadkeys"
167 }170 }
171+
172+// ValidateArchLinux checks if the system is running Arch Linux
173+// Returns an error if not running on Arch Linux or Arch ISO
174+func (r *SystemValidationRules) ValidateArchLinux(ctx context.Context, fs ports.FileSystem) error {
175+ exists, err := fs.Exists("/etc/arch-release")
176+ if err != nil {
177+ return errors.New("failed to check for Arch Linux: " + err.Error())
178+ }
179+ if !exists {
180+ return errors.New("must be running on Arch Linux or Arch ISO")
181+ }
182+ return nil
183+}
184+
185+// ValidateNotDerivative checks if the system is vanilla Arch (not a derivative)
186+// Returns an error if a derivative is detected (CachyOS, EndeavourOS, Garuda, Manjaro)
187+func (r *SystemValidationRules) ValidateNotDerivative(ctx context.Context, fs ports.FileSystem) error {
188+ derivatives := map[string]string{
189+ "/etc/cachyos-release": "CachyOS",
190+ "/etc/eos-release": "EndeavourOS",
191+ "/etc/garuda-release": "Garuda",
192+ "/etc/manjaro-release": "Manjaro",
193+ }
194+
195+ for marker, name := range derivatives {
196+ exists, err := fs.Exists(marker)
197+ if err != nil {
198+ // Continue checking other markers even if one fails
199+ continue
200+ }
201+ if exists {
202+ return errors.New("must be vanilla Arch (detected " + name + ")")
203+ }
204+ }
205+
206+ return nil
207+}
208+
209+// ValidateArchitecture checks if the system architecture is x86_64
210+// Returns an error if not running on x86_64 architecture
211+func (r *SystemValidationRules) ValidateArchitecture(arch string) error {
212+ if arch == "" {
213+ return errors.New("architecture cannot be empty")
214+ }
215+ if arch != "x86_64" {
216+ return errors.New("must be x86_64 architecture (detected: " + arch + ")")
217+ }
218+ return nil
219+}
220+
221+// ValidateUEFIBoot checks if the system is booted in UEFI mode
222+// Returns an error if not booted in UEFI mode (legacy BIOS is not supported)
223+func (r *SystemValidationRules) ValidateUEFIBoot(ctx context.Context, fs ports.FileSystem) error {
224+ exists, err := fs.Exists("/sys/firmware/efi/efivars")
225+ if err != nil {
226+ return errors.New("failed to check UEFI boot mode: " + err.Error())
227+ }
228+ if !exists {
229+ return errors.New("must be UEFI boot mode (legacy BIOS not supported)")
230+ }
231+ return nil
232+}
233+
234+// DetectSecureBoot checks if Secure Boot is enabled
235+// Returns true if enabled, false if disabled, and an error if detection fails
236+func (r *SystemValidationRules) DetectSecureBoot(ctx context.Context, exec ports.CommandExecutor) (bool, error) {
237+ output, err := exec.Execute(ctx, "bootctl", "status")
238+ if err != nil {
239+ // If bootctl fails, we can't determine Secure Boot status
240+ // This is not necessarily an error - might not be available
241+ return false, nil
242+ }
243+
244+ // Check if output contains "Secure Boot: enabled"
245+ if strings.Contains(string(output), "Secure Boot: enabled") {
246+ return true, nil
247+ }
248+
249+ return false, nil
250+}
251+
252+// ValidateSecureBootDisabled checks that Secure Boot is disabled
253+// Returns an error if Secure Boot is enabled
254+func (r *SystemValidationRules) ValidateSecureBootDisabled(ctx context.Context, exec ports.CommandExecutor) error {
255+ enabled, err := r.DetectSecureBoot(ctx, exec)
256+ if err != nil {
257+ return errors.New("failed to detect Secure Boot status: " + err.Error())
258+ }
259+ if enabled {
260+ return errors.New("Secure Boot must be disabled")
261+ }
262+ return nil
263+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close