Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.
Merged
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
25 changes: 13 additions & 12 deletions internal/packages/apk.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ func (m *APKManager) GetPackages() []models.Package {
m.logger.Debug("Getting installed packages...")
installedCmd := exec.Command("apk", "list", "--installed")
installedOutput, err := installedCmd.Output()
var installedPackages map[string]string
var installedPackages map[string]models.Package
if err != nil {
m.logger.WithError(err).Warn("Failed to get installed packages")
installedPackages = make(map[string]string)
installedPackages = make(map[string]models.Package)
} else {
m.logger.Debug("Parsing installed packages...")
installedPackages = m.parseInstalledPackages(string(installedOutput))
Expand Down Expand Up @@ -69,9 +69,9 @@ func (m *APKManager) GetPackages() []models.Package {

// parseInstalledPackages parses apk list --installed output
// Format: package-name-version-release arch {origin} (license) [installed]
// Example: alpine-base-3.22.2-r0 x86_64 {alpine-base} (MIT) [installed]
func (m *APKManager) parseInstalledPackages(output string) map[string]string {
installedPackages := make(map[string]string)
// Example: alpine-base-3.22.2-r0 x86_64// parseInstalledPackages parses apk info -v output
func (m *APKManager) parseInstalledPackages(output string) map[string]models.Package {
installedPackages := make(map[string]models.Package)

scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
Expand All @@ -97,15 +97,17 @@ func (m *APKManager) parseInstalledPackages(output string) map[string]string {
packageWithVersion := fields[0]

// Extract package name and version-release
// Format: package-name-version-release
// We need to find where the version starts (first dash followed by a digit)
packageName, version := m.extractPackageNameAndVersion(packageWithVersion)
if packageName == "" || version == "" {
m.logger.WithField("line", line).Debug("Failed to extract package name or version")
continue
}

installedPackages[packageName] = version
installedPackages[packageName] = models.Package{
Name: packageName,
CurrentVersion: version,
NeedsUpdate: false,
}
}

return installedPackages
Expand All @@ -114,7 +116,7 @@ func (m *APKManager) parseInstalledPackages(output string) map[string]string {
// parseUpgradablePackages parses apk -u list output
// Format: package-name-new-version arch {origin} (license) [upgradable from: package-name-old-version]
// Example: alpine-conf-3.20.0-r1 x86_64 {alpine-conf} (MIT) [upgradable from: alpine-conf-3.20.0-r0]
func (m *APKManager) parseUpgradablePackages(output string, installedPackages map[string]string) []models.Package {
func (m *APKManager) parseUpgradablePackages(output string, installedPackages map[string]models.Package) []models.Package {
var packages []models.Package

// Regex to match the upgradable from pattern
Expand Down Expand Up @@ -167,8 +169,8 @@ func (m *APKManager) parseUpgradablePackages(output string, installedPackages ma

// Use the current version from installed packages if available, otherwise use old version
currentVersion := oldVersion
if installedVersion, found := installedPackages[newPackageName]; found {
currentVersion = installedVersion
if installedPkg, found := installedPackages[newPackageName]; found {
currentVersion = installedPkg.CurrentVersion
}

// Alpine doesn't have built-in security update tracking
Expand Down Expand Up @@ -211,4 +213,3 @@ func (m *APKManager) extractPackageNameAndVersion(packageWithVersion string) (pa
packageName = packageWithVersion
return
}

49 changes: 39 additions & 10 deletions internal/packages/apt.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,13 @@ func (m *APTManager) GetPackages() []models.Package {

// Get installed packages
m.logger.Debug("Getting installed packages...")
installedCmd := exec.Command("dpkg-query", "-W", "-f", "${Package} ${Version}\n")
// Note: Description can be multiline. Multiline descriptions in debian packages usually have subsequent lines indented.
installedCmd := exec.Command("dpkg-query", "-W", "-f", "${Package} ${Version} ${Description}\n")
installedOutput, err := installedCmd.Output()
var installedPackages map[string]string
var installedPackages map[string]models.Package
if err != nil {
m.logger.WithError(err).Warn("Failed to get installed packages")
installedPackages = make(map[string]string)
installedPackages = make(map[string]models.Package)
} else {
m.logger.Debug("Parsing installed packages...")
installedPackages = m.parseInstalledPackages(string(installedOutput))
Expand Down Expand Up @@ -152,25 +153,53 @@ func (m *APTManager) parseAPTUpgrade(output string) []models.Package {
}

// parseInstalledPackages parses dpkg-query output and returns a map of package name to version
func (m *APTManager) parseInstalledPackages(output string) map[string]string {
installedPackages := make(map[string]string)
func (m *APTManager) parseInstalledPackages(output string) map[string]models.Package {
installedPackages := make(map[string]models.Package)

scanner := bufio.NewScanner(strings.NewReader(output))
var currentPkg *models.Package

for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
line := scanner.Text() // Preserve whitespace for description continuation detection
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
continue
}

// Check if this line is a continuation of the description (starts with space)
if strings.HasPrefix(line, " ") && currentPkg != nil {
// It's a description continuation
// For now, we can append it or just skip if we only want the summary.
// Let's append it to have full description, joining with newline
currentPkg.Description += "\n" + trimmedLine
installedPackages[currentPkg.Name] = *currentPkg // Update map
continue
}

parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
// New package line: Package Version Description
// We use SplitN with 3 parts. Description is the rest.
parts := strings.SplitN(trimmedLine, " ", 3)
if len(parts) < 2 {
m.logger.WithField("line", line).Debug("Skipping malformed installed package line")
currentPkg = nil
continue
}

packageName := parts[0]
version := parts[1]
installedPackages[packageName] = version
description := ""
if len(parts) == 3 {
description = parts[2]
}

pkg := models.Package{
Name: packageName,
CurrentVersion: version,
Description: description,
NeedsUpdate: false,
}
installedPackages[packageName] = pkg
currentPkg = &pkg
}

return installedPackages
Expand Down
40 changes: 28 additions & 12 deletions internal/packages/apt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,48 @@ func TestAPTManager_parseInstalledPackages(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
expected map[string]models.Package
}{
{
name: "valid single package",
input: `vim 2:8.2.3995-1ubuntu2.17
input: `vim 2:8.2.3995-1ubuntu2.17 Vi IMproved - enhanced vi editor
`,
expected: map[string]string{
"vim": "2:8.2.3995-1ubuntu2.17",
expected: map[string]models.Package{
"vim": {
Name: "vim",
CurrentVersion: "2:8.2.3995-1ubuntu2.17",
Description: "Vi IMproved - enhanced vi editor",
},
},
},
{
name: "multiple packages",
input: `vim 2:8.2.3995-1ubuntu2.17
libc6 2.35-0ubuntu3.8
bash 5.1-6ubuntu1.1
input: `vim 2:8.2.3995-1ubuntu2.17 Vi IMproved
libc6 2.35-0ubuntu3.8 GNU C Library
bash 5.1-6ubuntu1.1 GNU Bourne Again SHell
`,
expected: map[string]string{
"vim": "2:8.2.3995-1ubuntu2.17",
"libc6": "2.35-0ubuntu3.8",
"bash": "5.1-6ubuntu1.1",
expected: map[string]models.Package{
"vim": {
Name: "vim",
CurrentVersion: "2:8.2.3995-1ubuntu2.17",
Description: "Vi IMproved",
},
"libc6": {
Name: "libc6",
CurrentVersion: "2.35-0ubuntu3.8",
Description: "GNU C Library",
},
"bash": {
Name: "bash",
CurrentVersion: "5.1-6ubuntu1.1",
Description: "GNU Bourne Again SHell",
},
},
},
{
name: "empty input",
input: "",
expected: map[string]string{},
expected: map[string]models.Package{},
},
}

Expand Down
55 changes: 31 additions & 24 deletions internal/packages/dnf.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ func (m *DNFManager) GetPackages() []models.Package {
// Get installed packages
m.logger.Debug("Getting installed packages...")
listCmd := exec.Command(packageManager, "list", "--installed")
listOutput, err := listCmd.Output()
var installedPackages map[string]string
installedOutput, err := listCmd.Output()
var installedPackages map[string]models.Package
if err != nil {
m.logger.WithError(err).Error("Failed to get installed packages - this will result in 0 packages being reported")
installedPackages = make(map[string]string)
m.logger.WithError(err).Warn("Failed to get installed packages")
installedPackages = make(map[string]models.Package)
} else {
m.logger.WithField("outputSize", len(listOutput)).Debug("Received output from list installed command")
m.logger.WithField("outputSize", len(installedOutput)).Debug("Received output from list installed command")
m.logger.Debug("Parsing installed packages...")
installedPackages = m.parseInstalledPackages(string(listOutput))
installedPackages = m.parseInstalledPackages(string(installedOutput))
m.logger.WithField("count", len(installedPackages)).Info("Found installed packages")

if len(installedPackages) == 0 {
Expand Down Expand Up @@ -205,7 +205,7 @@ func (m *DNFManager) extractBasePackageName(packageString string) string {
}

// parseUpgradablePackages parses dnf/yum check-update output
func (m *DNFManager) parseUpgradablePackages(output string, packageManager string, installedPackages map[string]string, securityPackages map[string]bool) []models.Package {
func (m *DNFManager) parseUpgradablePackages(output string, packageManager string, installedPackages map[string]models.Package, securityPackages map[string]bool) []models.Package {
var packages []models.Package

scanner := bufio.NewScanner(strings.NewReader(output))
Expand All @@ -228,7 +228,10 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin

// Get current version from installed packages map (already collected)
// Try exact match first
currentVersion := installedPackages[packageName]
var currentVersion string
if p, ok := installedPackages[packageName]; ok {
currentVersion = p.CurrentVersion
}

// If not found, try to find by base name (handles architecture suffixes)
// e.g., if packageName is "package" but installed has "package.x86_64"
Expand All @@ -241,13 +244,15 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin
if archSuffix == "x86_64" || archSuffix == "i686" || archSuffix == "i386" ||
archSuffix == "noarch" || archSuffix == "aarch64" || archSuffix == "arm64" {
basePackageName = packageName[:idx]
currentVersion = installedPackages[basePackageName]
if p, ok := installedPackages[basePackageName]; ok {
currentVersion = p.CurrentVersion
}
}
}

// If still not found, search through installed packages for matching base name
if currentVersion == "" {
for installedName, version := range installedPackages {
for installedName, p := range installedPackages {
// Remove architecture suffix if present (e.g., .x86_64, .noarch, .i686)
baseName := installedName
if idx := strings.LastIndex(installedName, "."); idx > 0 {
Expand All @@ -261,7 +266,7 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin

// Compare base names (handles both cases: package vs package.x86_64)
if baseName == basePackageName || baseName == packageName {
currentVersion = version
currentVersion = p.CurrentVersion
break
}
}
Expand All @@ -273,7 +278,7 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin
getCurrentCmd := exec.Command(packageManager, "list", "--installed", packageName)
getCurrentOutput, err := getCurrentCmd.Output()
if err == nil {
for currentLine := range strings.SplitSeq(string(getCurrentOutput), "\n") {
for _, currentLine := range strings.Split(string(getCurrentOutput), "\n") {
if strings.Contains(currentLine, packageName) && !strings.Contains(currentLine, "Installed") && !strings.Contains(currentLine, "Available") {
currentFields := slices.Collect(strings.FieldsSeq(currentLine))
if len(currentFields) >= 2 {
Expand Down Expand Up @@ -311,28 +316,30 @@ func (m *DNFManager) parseUpgradablePackages(output string, packageManager strin
return packages
}

// parseInstalledPackages parses dnf/yum list installed output and returns a map of package name to version
func (m *DNFManager) parseInstalledPackages(output string) map[string]string {
installedPackages := make(map[string]string)
// parseInstalledPackages parses dnf list installed output
func (m *DNFManager) parseInstalledPackages(output string) map[string]models.Package {
installedPackages := make(map[string]models.Package)

scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())

// Skip header lines and empty lines
if line == "" || strings.Contains(line, "Loaded plugins") ||
strings.Contains(line, "Installed Packages") {
if line == "" || strings.HasPrefix(line, "Installed Packages") {
continue
}

fields := slices.Collect(strings.FieldsSeq(line))
if len(fields) < 2 {
parts := strings.Fields(line)
if len(parts) < 3 {
continue
}

packageName := fields[0]
version := fields[1]
installedPackages[packageName] = version
packageName := strings.Split(parts[0], ".")[0] // Remove architecture
version := parts[1]

installedPackages[packageName] = models.Package{
Name: packageName,
CurrentVersion: version,
NeedsUpdate: false,
}
}

return installedPackages
Expand Down
Loading