diff --git a/go.mod b/go.mod index 390fd8e..86fa4fb 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + gopkg.in/ini.v1 v1.67.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index c6655a6..f5bcbb4 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/constants/constants.go b/internal/constants/constants.go index a85d79e..03f2646 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -51,6 +51,7 @@ const ( RepoTypeDebSrc = "deb-src" RepoTypeRPM = "rpm" RepoTypeAPK = "apk" + RepoTypePacman = "pacman" ) // Log level constants diff --git a/internal/packages/packages.go b/internal/packages/packages.go index 87552bc..4e931d5 100644 --- a/internal/packages/packages.go +++ b/internal/packages/packages.go @@ -11,10 +11,11 @@ import ( // Manager handles package information collection type Manager struct { - logger *logrus.Logger - aptManager *APTManager - dnfManager *DNFManager - apkManager *APKManager + logger *logrus.Logger + aptManager *APTManager + dnfManager *DNFManager + apkManager *APKManager + pacmanManager *PacmanManager } // New creates a new package manager @@ -22,12 +23,14 @@ func New(logger *logrus.Logger) *Manager { aptManager := NewAPTManager(logger) dnfManager := NewDNFManager(logger) apkManager := NewAPKManager(logger) + pacmanManager := NewPacmanManager(logger) return &Manager{ - logger: logger, - aptManager: aptManager, - dnfManager: dnfManager, - apkManager: apkManager, + logger: logger, + aptManager: aptManager, + dnfManager: dnfManager, + apkManager: apkManager, + pacmanManager: pacmanManager, } } @@ -44,6 +47,8 @@ func (m *Manager) GetPackages() ([]models.Package, error) { return m.dnfManager.GetPackages(), nil case "apk": return m.apkManager.GetPackages(), nil + case "pacman": + return m.pacmanManager.GetPackages() default: return nil, fmt.Errorf("unsupported package manager: %s", packageManager) } @@ -72,6 +77,11 @@ func (m *Manager) detectPackageManager() string { return "yum" } + // Check for Pacman + if _, err := exec.LookPath("pacman"); err == nil { + return "pacman" + } + return "unknown" } diff --git a/internal/packages/pacman.go b/internal/packages/pacman.go new file mode 100644 index 0000000..a7a0d10 --- /dev/null +++ b/internal/packages/pacman.go @@ -0,0 +1,126 @@ +package packages + +import ( + "bufio" + "errors" + "os/exec" + "regexp" + "strings" + + "patchmon-agent/pkg/models" + + "github.com/sirupsen/logrus" +) + +var installedPackageRe = regexp.MustCompile(`^(\S+)\s+(\S+)$`) +var checkUpdateRe = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)$`) + +// PacmanManager handles pacman package information collection +type PacmanManager struct { + logger *logrus.Logger +} + +// NewPacmanManager creates a new Pacman package manager +func NewPacmanManager(logger *logrus.Logger) *PacmanManager { + return &PacmanManager{ + logger: logger, + } +} + +// indirections for testability +var ( + lookPath = exec.LookPath + runCommand = exec.Command +) + +// GetPackages gets package information for pacman-based systems +func (m *PacmanManager) GetPackages() ([]models.Package, error) { + // Get installed packages + installedCmd := runCommand("pacman", "-Q") + installedOutput, err := installedCmd.Output() + var installedPackages map[string]string + if err != nil { + m.logger.WithError(err).Error("Failed to get installed packages") + installedPackages = make(map[string]string) + } else { + installedPackages = m.parseInstalledPackages(string(installedOutput)) + } + + upgradablePackages, err := m.getUpgradablePackages() + if err != nil { + return nil, err + } + + // Merge and deduplicate packages + packages := CombinePackageData(installedPackages, upgradablePackages) + return packages, nil +} + +// getUpgradablePackages runs checkupdates and returns parsed packages. +func (m *PacmanManager) getUpgradablePackages() ([]models.Package, error) { + if _, err := lookPath("checkupdates"); err != nil { + m.logger.WithError(err).Error("checkupdates not found (pacman-contrib not installed)") + return nil, err + } + + upgradeCmd := runCommand("checkupdates") + upgradeOutput, err := upgradeCmd.Output() + if err != nil { + // 0 = success with output, 1 = unknown failure, 2 = no updates available. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if exitErr.ExitCode() == 2 { + return []models.Package{}, nil + } + } + m.logger.WithError(err).Error("checkupdates failed") + return nil, err + } + + pkgs := m.parseCheckUpdate(string(upgradeOutput)) + return pkgs, nil +} + +// parseCheckUpdate parses checkupdates output +func (m *PacmanManager) parseCheckUpdate(output string) []models.Package { + packages := make([]models.Package, 0) + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + matches := checkUpdateRe.FindStringSubmatch(line) + if matches == nil { + continue + } + + pkg := models.Package{ + Name: matches[1], + CurrentVersion: matches[2], + AvailableVersion: matches[3], + NeedsUpdate: true, + IsSecurityUpdate: false, // Data not provided + } + packages = append(packages, pkg) + } + + return packages +} + +// parseInstalledPackages parses pacman -Q output and returns a map of package name to version +func (m *PacmanManager) parseInstalledPackages(output string) map[string]string { + installedPackages := make(map[string]string) + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + matches := installedPackageRe.FindStringSubmatch(scanner.Text()) + if matches == nil { + continue + } + + packageName := matches[1] + version := matches[2] + installedPackages[packageName] = version + } + + return installedPackages +} diff --git a/internal/packages/pacman_test.go b/internal/packages/pacman_test.go new file mode 100644 index 0000000..ccbc67c --- /dev/null +++ b/internal/packages/pacman_test.go @@ -0,0 +1,202 @@ +package packages + +import ( + "testing" + + "patchmon-agent/pkg/models" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestPacmanManager_parseInstalledPackages(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + manager := NewPacmanManager(logger) + + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "valid single package", + input: `vim 9.1.0123-1 +`, + expected: map[string]string{ + "vim": "9.1.0123-1", + }, + }, + { + name: "multiple packages", + input: `vim 9.1.0123-1 +glibc 2.39-3 +bash 5.2.037-1 +`, + expected: map[string]string{ + "vim": "9.1.0123-1", + "glibc": "2.39-3", + "bash": "5.2.037-1", + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "ignores malformed lines", + input: `vim 9.1.0123-1 +this-is-not-valid +two spaces here +okpkg 1.0.0 +`, + expected: map[string]string{ + "vim": "9.1.0123-1", + "okpkg": "1.0.0", + }, + }, + { + name: "whitespace-only line is ignored", + input: `vim 9.1.0123-1 + +okpkg 1.0.0 +`, + expected: map[string]string{ + "vim": "9.1.0123-1", + "okpkg": "1.0.0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := manager.parseInstalledPackages(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPacmanManager_parseCheckUpdate(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + manager := NewPacmanManager(logger) + + tests := []struct { + name string + input string + expected []models.Package + }{ + { + name: "standard update", + input: `vim 9.1.0123-1 -> 9.1.0456-1`, + expected: []models.Package{ + { + Name: "vim", + CurrentVersion: "9.1.0123-1", + AvailableVersion: "9.1.0456-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + }, + }, + { + name: "multiple updates", + input: `vim 9.1.0123-1 -> 9.1.0456-1 +glibc 2.39-3 -> 2.40-1 +bash 5.2.037-1 -> 5.2.040-1 +`, + expected: []models.Package{ + { + Name: "vim", + CurrentVersion: "9.1.0123-1", + AvailableVersion: "9.1.0456-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + { + Name: "glibc", + CurrentVersion: "2.39-3", + AvailableVersion: "2.40-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + { + Name: "bash", + CurrentVersion: "5.2.037-1", + AvailableVersion: "5.2.040-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + }, + }, + { + name: "empty input", + input: "", + expected: []models.Package{}, + }, + { + name: "ignores malformed lines", + input: `vim 9.1.0123-1 -> 9.1.0456-1 +this is not checkupdates output +pkg 1.0.0 -> 2.0.0 extra-field +okpkg 1 -> 2 +`, + expected: []models.Package{ + { + Name: "vim", + CurrentVersion: "9.1.0123-1", + AvailableVersion: "9.1.0456-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + { + Name: "okpkg", + CurrentVersion: "1", + AvailableVersion: "2", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + }, + }, + { + name: "requires exact arrow formatting with spaces", + input: `vim 9.1.0123-1->9.1.0456-1 +vim 9.1.0123-1 -> 9.1.0456-1 +`, + expected: []models.Package{ + { + Name: "vim", + CurrentVersion: "9.1.0123-1", + AvailableVersion: "9.1.0456-1", + NeedsUpdate: true, + IsSecurityUpdate: false, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := manager.parseCheckUpdate(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPacmanManager_getUpgradablePackages_missingCheckupdates(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + manager := NewPacmanManager(logger) + + // Override lookPath to simulate missing checkupdates + origLookPath := lookPath + lookPath = func(file string) (string, error) { + return "", assert.AnError + } + defer func() { lookPath = origLookPath }() + + pkgs, err := manager.getUpgradablePackages() + assert.Error(t, err) + assert.Nil(t, pkgs) +} diff --git a/internal/repositories/pacman.go b/internal/repositories/pacman.go new file mode 100644 index 0000000..3f3cdaf --- /dev/null +++ b/internal/repositories/pacman.go @@ -0,0 +1,180 @@ +package repositories + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "patchmon-agent/internal/constants" + "patchmon-agent/pkg/models" + + "github.com/sirupsen/logrus" + ini "gopkg.in/ini.v1" +) + +// PacmanManager handles repository collection for pacman-based systems +type PacmanManager struct { + logger *logrus.Logger +} + +// NewPacmanManager creates a new PacmanManager +func NewPacmanManager(logger *logrus.Logger) *PacmanManager { + return &PacmanManager{logger: logger} +} + +// GetRepositories parses /etc/pacman.conf and included files for repositories +func (p *PacmanManager) GetRepositories() ([]models.Repository, error) { + return p.parsePacmanConf("/etc/pacman.conf") +} + +// parsePacmanConf parses the main pacman configuration file and collects repositories +func (p *PacmanManager) parsePacmanConf(filename string) ([]models.Repository, error) { + // pacman.conf allows bare boolean keys like "Color"; enable AllowBooleanKeys. + // Also make key names case-insensitive to be resilient. + cfg, err := ini.LoadSources(ini.LoadOptions{ + AllowBooleanKeys: true, + Insensitive: true, + }, filename) + if err != nil { + p.logger.WithError(err).WithField("file", filename).Warn("Failed to load pacman.conf as INI") + return []models.Repository{}, nil + } + + var repos []models.Repository + + // Iterate through sections; any section other than [options] is a repository + for _, section := range cfg.Sections() { + name := section.Name() + // Default section is often named DEFAULT by ini lib; skip it + if name == ini.DefaultSection || strings.EqualFold(name, "options") { + continue + } + + repoName := strings.ToLower(strings.TrimSpace(name)) + + // Collect Server entries (there might be 0 or 1 typically in pacman.conf) + if key, err := section.GetKey("Server"); err == nil { + // ini can keep shadow values if repeated; include all if present + values := key.ValueWithShadows() + if len(values) == 0 { + values = []string{key.String()} + } + for _, v := range values { + url := strings.TrimSpace(v) + if !p.isValidRepoURL(url) { + continue + } + repos = append(repos, p.buildRepoEntry(repoName, url)) + } + } + + // Follow Include entries, which may contain globs to mirrorlist files + if key, err := section.GetKey("Include"); err == nil { + includes := key.ValueWithShadows() + if len(includes) == 0 { + includes = []string{key.String()} + } + for _, incPattern := range includes { + for _, inc := range p.expandIncludeGlobs(strings.TrimSpace(incPattern)) { + incRepos := p.parseMirrorList(inc, repoName) + repos = append(repos, incRepos...) + } + } + } + } + + return repos, nil +} + +// parseMirrorList parses an included mirrorlist file and extracts Server URLs +func (p *PacmanManager) parseMirrorList(filename string, repoName string) []models.Repository { + file, err := os.Open(filename) + if err != nil { + p.logger.WithError(err).WithField("file", filename).Debug("Failed to open include file") + return nil + } + defer file.Close() + + var repos []models.Repository + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + continue + } + + // Expect lines like: Server = https://mirror/archlinux/$repo/os/$arch + // Do a simple case-insensitive prefix check and split on '=' + // Allow extra spaces around '=' + // Identify key + // Find '=' position + eq := strings.IndexRune(trimmed, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(trimmed[:eq]) + val := strings.TrimSpace(trimmed[eq+1:]) + if strings.EqualFold(key, "Server") { + url := val + if !p.isValidRepoURL(url) { + continue + } + repos = append(repos, p.buildRepoEntry(repoName, url)) + } + } + + if err := scanner.Err(); err != nil { + p.logger.WithError(err).WithField("file", filename).Debug("Error reading include file") + } + + return repos +} + +// buildRepoEntry builds a Repository object for pacman server URL +func (p *PacmanManager) buildRepoEntry(section string, url string) models.Repository { + // In pacman, the section name is the repository name + name := section + + // Distribution/Components are not applicable in pacman; set distribution to section for context + distribution := section + components := "" + + return models.Repository{ + Name: name, + URL: url, + Distribution: distribution, + Components: components, + RepoType: constants.RepoTypePacman, + IsEnabled: true, // if present and not commented, it's enabled + IsSecure: p.isSecureURL(url), + } +} + +// expandIncludeGlobs handles Include directives that may contain globs +func (p *PacmanManager) expandIncludeGlobs(pattern string) []string { + // Pacman allows simple file paths; if globbing fails, return the pattern itself if file exists + matches, err := filepath.Glob(pattern) + if err == nil && len(matches) > 0 { + return matches + } + if _, err := os.Stat(pattern); err == nil { + return []string{pattern} + } + return nil +} + +// isValidRepoURL does a basic sanity check for URLs we can report +func (p *PacmanManager) isValidRepoURL(url string) bool { + u := strings.ToLower(strings.TrimSpace(url)) + return strings.HasPrefix(u, "http://") || + strings.HasPrefix(u, "https://") || + strings.HasPrefix(u, "ftp://") +} + +// isSecureURL checks if URL uses HTTPS +func (p *PacmanManager) isSecureURL(url string) bool { + return strings.HasPrefix(strings.ToLower(url), "https://") +} diff --git a/internal/repositories/pacman_test.go b/internal/repositories/pacman_test.go new file mode 100644 index 0000000..2422b05 --- /dev/null +++ b/internal/repositories/pacman_test.go @@ -0,0 +1,201 @@ +package repositories + +import ( + "os" + "path/filepath" + "testing" + + "patchmon-agent/pkg/models" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestPacmanManager() *PacmanManager { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + return NewPacmanManager(logger) +} + +func TestPacman_parsePacmanConf_withServersAndIncludes(t *testing.T) { + m := newTestPacmanManager() + dir := t.TempDir() + + // Create included mirrorlist file + mirrorPath := filepath.Join(dir, "mirrorlist") + mirrorContent := ` +# Arch mirrors +Server = https://mirror1.example.org/archlinux/$repo/os/$arch +; comment + Server= http://insecure.example.net/archlinux/$repo/os/$arch +` + require.NoError(t, os.WriteFile(mirrorPath, []byte(mirrorContent), 0o644)) + + // Create main pacman.conf + confPath := filepath.Join(dir, "pacman.conf") + confContent := ` +[options] +Color + +[core] +Include = ` + mirrorPath + ` + +[custom] +Server = https://repo.example.com/$repo/os/$arch + +[insecure] +Server = http://repo.insecure.local/$repo/os/$arch + +# [commented.out] +# Include = ` + mirrorPath + ` +# Server = https://this.should.not.show.up + +[commented.include] +Server = https://repo.example.com/$repo/os/$arch +# Include = ` + mirrorPath + ` +` + require.NoError(t, os.WriteFile(confPath, []byte(confContent), 0o644)) + + repos, err := m.parsePacmanConf(confPath) + require.NoError(t, err) + + // Expect 1 from [custom], 1 from [insecure], and 2 from [core] includes = 4 + // However, only URLs with http/https/file are accepted; all entries qualify. + assert.Len(t, repos, 5) + + // Validate a few properties + // Find custom https + var foundCustom, foundCoreHTTPS, foundCoreHTTP, foundInsecure bool + for _, r := range repos { + switch r.Name { + case "custom": + if r.URL == "https://repo.example.com/$repo/os/$arch" { + foundCustom = true + assert.True(t, r.IsSecure) + } + case "core": + if r.URL == "https://mirror1.example.org/archlinux/$repo/os/$arch" { + foundCoreHTTPS = true + assert.True(t, r.IsSecure) + } + if r.URL == "http://insecure.example.net/archlinux/$repo/os/$arch" { + foundCoreHTTP = true + assert.False(t, r.IsSecure) + } + case "insecure": + if r.URL == "http://repo.insecure.local/$repo/os/$arch" { + foundInsecure = true + assert.False(t, r.IsSecure) + } + case "commented.out": + assert.Fail(t, "Commented sections should not be parsed") + case "commented.include": + // Fail if we ended up actually including this + if r.URL == "https://mirror1.example.org/archlinux/$repo/os/$arch" { + assert.Fail(t, "We seem to have traversed a commented out include.") + } + } + // Common expectations + assert.Equal(t, r.Name, r.Distribution) + assert.Equal(t, "", r.Components) + assert.True(t, r.IsEnabled) + } + + assert.True(t, foundCustom) + assert.True(t, foundCoreHTTPS) + assert.True(t, foundCoreHTTP) + assert.True(t, foundInsecure) +} + +func TestPacman_expandIncludeGlobs(t *testing.T) { + m := newTestPacmanManager() + dir := t.TempDir() + + // Create two mirrorlist files matching a glob + ml1 := filepath.Join(dir, "m1.list") + ml2 := filepath.Join(dir, "m2.list") + require.NoError(t, os.WriteFile(ml1, []byte("Server = https://a.example/arch/$repo/os/$arch\n"), 0o644)) + require.NoError(t, os.WriteFile(ml2, []byte("Server = https://b.example/arch/$repo/os/$arch\n"), 0o644)) + + // Build a pacman.conf that includes the glob + conf := filepath.Join(dir, "pacman.conf") + content := "[core]\nInclude = " + filepath.Join(dir, "*.list") + "\n" + require.NoError(t, os.WriteFile(conf, []byte(content), 0o644)) + + repos, err := m.parsePacmanConf(conf) + require.NoError(t, err) + assert.Len(t, repos, 2) + + urls := map[string]bool{} + for _, r := range repos { + urls[r.URL] = true + assert.Equal(t, "core", r.Name) + } + assert.True(t, urls["https://a.example/arch/$repo/os/$arch"]) + assert.True(t, urls["https://b.example/arch/$repo/os/$arch"]) +} + +func TestPacman_parseMirrorList_ignoresCommentsAndMalformed(t *testing.T) { + m := newTestPacmanManager() + dir := t.TempDir() + + file := filepath.Join(dir, "mirrorlist") + data := ` +# comment +; another +NotAKey = something +Server = https://valid.example/arch/$repo/os/$arch +Server https://missing.equals/arch/$repo/os/$arch +Server = ftp://unsupported.example/path +Server = file:///should/be/ignored +` + require.NoError(t, os.WriteFile(file, []byte(data), 0o644)) + + repos := m.parseMirrorList(file, "extra") + // Two valid: https and ftp; file:// must be ignored to match DNF behavior; malformed is ignored + require.Len(t, repos, 2) + + // Build a quick lookup for assertions + byURL := map[string]models.Repository{} + for _, r := range repos { + byURL[r.URL] = r + assert.Equal(t, "extra", r.Name) + assert.Equal(t, "extra", r.Distribution) + } + + httpsRepo, ok := byURL["https://valid.example/arch/$repo/os/$arch"] + require.True(t, ok) + assert.True(t, httpsRepo.IsSecure) + + ftpRepo, ok := byURL["ftp://unsupported.example/path"] + require.True(t, ok) + assert.False(t, ftpRepo.IsSecure) + + // Ensure file:// is excluded + _, hasFile := byURL["file:///should/be/ignored"] + assert.False(t, hasFile) +} + +func TestPacman_isValidRepoURL(t *testing.T) { + m := &PacmanManager{} + + tests := []struct { + name string + url string + expected bool + }{ + {"http URL", "http://example.com", true}, + {"https URL", "https://example.com", true}, + {"ftp URL", "ftp://example.com", true}, + {"file URL", "file:///local/path", false}, + {"empty URL", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := m.isValidRepoURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/repositories/repositories.go b/internal/repositories/repositories.go index 1a3581f..1681301 100644 --- a/internal/repositories/repositories.go +++ b/internal/repositories/repositories.go @@ -10,19 +10,21 @@ import ( // Manager handles repository information collection type Manager struct { - logger *logrus.Logger - aptManager *APTManager - dnfManager *DNFManager - apkManager *APKManager + logger *logrus.Logger + aptManager *APTManager + dnfManager *DNFManager + apkManager *APKManager + pacmanManager *PacmanManager } // New creates a new repository manager func New(logger *logrus.Logger) *Manager { return &Manager{ - logger: logger, - aptManager: NewAPTManager(logger), - dnfManager: NewDNFManager(logger), - apkManager: NewAPKManager(logger), + logger: logger, + aptManager: NewAPTManager(logger), + dnfManager: NewDNFManager(logger), + apkManager: NewAPKManager(logger), + pacmanManager: NewPacmanManager(logger), } } @@ -40,6 +42,8 @@ func (m *Manager) GetRepositories() ([]models.Repository, error) { return repos, nil case "apk": return m.apkManager.GetRepositories() + case "pacman": + return m.pacmanManager.GetRepositories() default: m.logger.WithField("package_manager", packageManager).Warn("Unsupported package manager") return []models.Repository{}, nil @@ -53,6 +57,11 @@ func (m *Manager) detectPackageManager() string { return "apk" } + // Check for Pacman (Arch Linux and derivatives) + if _, err := exec.LookPath("pacman"); err == nil { + return "pacman" + } + // Check for APT if _, err := exec.LookPath("apt"); err == nil { return "apt"