feat: add dnf and rpm package manager implementations

Implements the dnf sub-package with Manager, VersionQuerier, Holder,
Cleaner, FileOwner, RepoManager, KeyManager, Grouper, and NameNormalizer
interfaces.

Implements the rpm sub-package with Manager, FileOwner, and
NameNormalizer interfaces.

Both follow the existing pattern: exported methods on struct delegate to
unexported functions, _linux.go for real implementations, _other.go with
build-tag stubs, embedded snack.Locker for mutating operations, and
compile-time interface checks.

Includes parser tests for all output formats.
This commit is contained in:
2026-02-25 22:30:06 +00:00
parent 0ebebfd93b
commit 2685dd945c
15 changed files with 1862 additions and 2 deletions

147
dnf/capabilities.go Normal file
View File

@@ -0,0 +1,147 @@
package dnf
import (
"context"
"github.com/gogrlx/snack"
)
// Compile-time interface checks.
var (
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
)
// LatestVersion returns the latest available version from configured repositories.
func (d *DNF) LatestVersion(ctx context.Context, pkg string) (string, error) {
return latestVersion(ctx, pkg)
}
// ListUpgrades returns packages that have newer versions available.
func (d *DNF) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
return listUpgrades(ctx)
}
// UpgradeAvailable reports whether a newer version is available.
func (d *DNF) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
return upgradeAvailable(ctx, pkg)
}
// VersionCmp compares two version strings using RPM version comparison.
func (d *DNF) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
return versionCmp(ctx, ver1, ver2)
}
// Hold pins packages at their current version.
func (d *DNF) Hold(ctx context.Context, pkgs []string) error {
d.Lock()
defer d.Unlock()
return hold(ctx, pkgs)
}
// Unhold removes version pins.
func (d *DNF) Unhold(ctx context.Context, pkgs []string) error {
d.Lock()
defer d.Unlock()
return unhold(ctx, pkgs)
}
// ListHeld returns all currently held packages.
func (d *DNF) ListHeld(ctx context.Context) ([]snack.Package, error) {
return listHeld(ctx)
}
// Autoremove removes orphaned packages.
func (d *DNF) Autoremove(ctx context.Context, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return autoremove(ctx, opts...)
}
// Clean removes cached package files.
func (d *DNF) Clean(ctx context.Context) error {
d.Lock()
defer d.Unlock()
return clean(ctx)
}
// FileList returns all files installed by a package.
func (d *DNF) FileList(ctx context.Context, pkg string) ([]string, error) {
return fileList(ctx, pkg)
}
// Owner returns the package that owns a given file path.
func (d *DNF) Owner(ctx context.Context, path string) (string, error) {
return owner(ctx, path)
}
// ListRepos returns all configured package repositories.
func (d *DNF) ListRepos(ctx context.Context) ([]snack.Repository, error) {
return listRepos(ctx)
}
// AddRepo adds a new package repository.
func (d *DNF) AddRepo(ctx context.Context, repo snack.Repository) error {
d.Lock()
defer d.Unlock()
return addRepo(ctx, repo)
}
// RemoveRepo removes a configured repository.
func (d *DNF) RemoveRepo(ctx context.Context, id string) error {
d.Lock()
defer d.Unlock()
return removeRepo(ctx, id)
}
// AddKey imports a GPG key for package verification.
func (d *DNF) AddKey(ctx context.Context, key string) error {
d.Lock()
defer d.Unlock()
return addKey(ctx, key)
}
// RemoveKey removes a GPG key.
func (d *DNF) RemoveKey(ctx context.Context, keyID string) error {
d.Lock()
defer d.Unlock()
return removeKey(ctx, keyID)
}
// ListKeys returns all trusted package signing keys.
func (d *DNF) ListKeys(ctx context.Context) ([]string, error) {
return listKeys(ctx)
}
// GroupList returns all available package groups.
func (d *DNF) GroupList(ctx context.Context) ([]string, error) {
return groupList(ctx)
}
// GroupInfo returns the packages in a group.
func (d *DNF) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) {
return groupInfo(ctx, group)
}
// GroupInstall installs all packages in a group.
func (d *DNF) GroupInstall(ctx context.Context, group string, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return groupInstall(ctx, group, opts...)
}
// NormalizeName returns the canonical form of a package name.
func (d *DNF) NormalizeName(name string) string {
return normalizeName(name)
}
// ParseArch extracts the architecture from a package name if present.
func (d *DNF) ParseArch(name string) (string, string) {
return parseArch(name)
}

231
dnf/capabilities_linux.go Normal file
View File

@@ -0,0 +1,231 @@
//go:build linux
package dnf
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gogrlx/snack"
)
func latestVersion(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"info", "--available", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return "", fmt.Errorf("dnf latestVersion: %w", err)
}
p := parseInfo(out)
if p == nil || p.Version == "" {
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
}
return p.Version, nil
}
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"list", "upgrades"}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("dnf listUpgrades: %w", err)
}
return parseList(out), nil
}
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "dnf", "list", "upgrades", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("dnf upgradeAvailable: %w", err)
}
return true, nil
}
func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
c := exec.CommandContext(ctx, "rpmdev-vercmp", ver1, ver2)
var stdout bytes.Buffer
c.Stdout = &stdout
err := c.Run()
out := strings.TrimSpace(stdout.String())
// rpmdev-vercmp exits 0 for equal, 11 for ver1 > ver2, 12 for ver1 < ver2
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
switch exitErr.ExitCode() {
case 11:
return 1, nil
case 12:
return -1, nil
}
}
return 0, fmt.Errorf("rpmdev-vercmp: %s: %w", out, err)
}
return 0, nil
}
func hold(ctx context.Context, pkgs []string) error {
args := append([]string{"versionlock", "add"}, pkgs...)
_, err := run(ctx, args, snack.Options{})
return err
}
func unhold(ctx context.Context, pkgs []string) error {
args := append([]string{"versionlock", "delete"}, pkgs...)
_, err := run(ctx, args, snack.Options{})
return err
}
func listHeld(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"versionlock", "list"}, snack.Options{})
if err != nil {
return nil, fmt.Errorf("dnf listHeld: %w", err)
}
return parseVersionLock(out), nil
}
func autoremove(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := run(ctx, []string{"autoremove", "-y"}, o)
return err
}
func clean(ctx context.Context) error {
_, err := run(ctx, []string{"clean", "all"}, snack.Options{})
return err
}
func fileList(ctx context.Context, pkg string) ([]string, error) {
c := exec.CommandContext(ctx, "rpm", "-ql", pkg)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "is not installed") {
return nil, fmt.Errorf("dnf fileList %s: %w", pkg, snack.ErrNotInstalled)
}
return nil, fmt.Errorf("rpm -ql: %s: %w", strings.TrimSpace(se), err)
}
var files []string
for _, line := range strings.Split(stdout.String(), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "(contains no files)") {
files = append(files, line)
}
}
return files, nil
}
func owner(ctx context.Context, path string) (string, error) {
c := exec.CommandContext(ctx, "rpm", "-qf", path)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "is not owned by any package") {
return "", fmt.Errorf("dnf owner %s: %w", path, snack.ErrNotFound)
}
return "", fmt.Errorf("rpm -qf: %s: %w", strings.TrimSpace(se), err)
}
return strings.TrimSpace(stdout.String()), nil
}
func listRepos(ctx context.Context) ([]snack.Repository, error) {
out, err := run(ctx, []string{"repolist", "--all"}, snack.Options{})
if err != nil {
return nil, fmt.Errorf("dnf listRepos: %w", err)
}
return parseRepoList(out), nil
}
func addRepo(ctx context.Context, repo snack.Repository) error {
_, err := run(ctx, []string{"config-manager", "--add-repo", repo.URL}, snack.Options{})
return err
}
func removeRepo(_ context.Context, id string) error {
// Remove the repo file from /etc/yum.repos.d/
repoFile := filepath.Join("/etc/yum.repos.d", id+".repo")
if err := os.Remove(repoFile); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("dnf removeRepo %s: %w", id, snack.ErrNotFound)
}
return fmt.Errorf("dnf removeRepo %s: %w", id, err)
}
return nil
}
func addKey(ctx context.Context, key string) error {
c := exec.CommandContext(ctx, "rpm", "--import", key)
var stderr bytes.Buffer
c.Stderr = &stderr
if err := c.Run(); err != nil {
return fmt.Errorf("rpm --import: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func removeKey(ctx context.Context, keyID string) error {
c := exec.CommandContext(ctx, "rpm", "-e", keyID)
var stderr bytes.Buffer
c.Stderr = &stderr
if err := c.Run(); err != nil {
return fmt.Errorf("rpm -e: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func listKeys(ctx context.Context) ([]string, error) {
c := exec.CommandContext(ctx, "rpm", "-qa", "gpg-pubkey*")
var stdout bytes.Buffer
c.Stdout = &stdout
if err := c.Run(); err != nil {
return nil, fmt.Errorf("rpm -qa gpg-pubkey: %w", err)
}
var keys []string
for _, line := range strings.Split(stdout.String(), "\n") {
line = strings.TrimSpace(line)
if line != "" {
keys = append(keys, line)
}
}
return keys, nil
}
func groupList(ctx context.Context) ([]string, error) {
out, err := run(ctx, []string{"group", "list"}, snack.Options{})
if err != nil {
return nil, fmt.Errorf("dnf groupList: %w", err)
}
return parseGroupList(out), nil
}
func groupInfo(ctx context.Context, group string) ([]snack.Package, error) {
out, err := run(ctx, []string{"group", "info", group}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("dnf groupInfo %s: %w", group, snack.ErrNotFound)
}
return nil, fmt.Errorf("dnf groupInfo: %w", err)
}
return parseGroupInfo(out), nil
}
func groupInstall(ctx context.Context, group string, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := run(ctx, []string{"group", "install", "-y", group}, o)
return err
}

89
dnf/capabilities_other.go Normal file
View File

@@ -0,0 +1,89 @@
//go:build !linux
package dnf
import (
"context"
"github.com/gogrlx/snack"
)
func latestVersion(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listUpgrades(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func versionCmp(_ context.Context, _, _ string) (int, error) {
return 0, snack.ErrUnsupportedPlatform
}
func hold(_ context.Context, _ []string) error {
return snack.ErrUnsupportedPlatform
}
func unhold(_ context.Context, _ []string) error {
return snack.ErrUnsupportedPlatform
}
func listHeld(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func autoremove(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func clean(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func fileList(_ context.Context, _ string) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func owner(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}
func listRepos(_ context.Context) ([]snack.Repository, error) {
return nil, snack.ErrUnsupportedPlatform
}
func addRepo(_ context.Context, _ snack.Repository) error {
return snack.ErrUnsupportedPlatform
}
func removeRepo(_ context.Context, _ string) error {
return snack.ErrUnsupportedPlatform
}
func addKey(_ context.Context, _ string) error {
return snack.ErrUnsupportedPlatform
}
func removeKey(_ context.Context, _ string) error {
return snack.ErrUnsupportedPlatform
}
func listKeys(_ context.Context) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func groupList(_ context.Context) ([]string, error) {
return nil, snack.ErrUnsupportedPlatform
}
func groupInfo(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func groupInstall(_ context.Context, _ string, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}

View File

@@ -1,2 +1,87 @@
// Package dnf provides Go bindings for DNF (Fedora/RHEL package manager).
// Package dnf provides Go bindings for the dnf package manager (Fedora/RHEL).
package dnf
import (
"context"
"github.com/gogrlx/snack"
)
// DNF wraps the dnf package manager CLI.
type DNF struct {
snack.Locker
}
// New returns a new DNF manager.
func New() *DNF {
return &DNF{}
}
// Name returns "dnf".
func (d *DNF) Name() string { return "dnf" }
// Available reports whether dnf is present on the system.
func (d *DNF) Available() bool { return available() }
// Install one or more packages.
func (d *DNF) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return install(ctx, pkgs, opts...)
}
// Remove one or more packages.
func (d *DNF) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return remove(ctx, pkgs, opts...)
}
// Purge removes packages including configuration files (same as Remove for dnf).
func (d *DNF) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return remove(ctx, pkgs, opts...)
}
// Upgrade all installed packages to their latest versions.
func (d *DNF) Upgrade(ctx context.Context, opts ...snack.Option) error {
d.Lock()
defer d.Unlock()
return upgrade(ctx, opts...)
}
// Update refreshes the package index/database.
func (d *DNF) Update(ctx context.Context) error {
d.Lock()
defer d.Unlock()
return update(ctx)
}
// List returns all installed packages.
func (d *DNF) List(ctx context.Context) ([]snack.Package, error) {
return list(ctx)
}
// Search queries the repositories for packages matching the query.
func (d *DNF) Search(ctx context.Context, query string) ([]snack.Package, error) {
return search(ctx, query)
}
// Info returns details about a specific package.
func (d *DNF) Info(ctx context.Context, pkg string) (*snack.Package, error) {
return info(ctx, pkg)
}
// IsInstalled reports whether a package is currently installed.
func (d *DNF) IsInstalled(ctx context.Context, pkg string) (bool, error) {
return isInstalled(ctx, pkg)
}
// Version returns the installed version of a package.
func (d *DNF) Version(ctx context.Context, pkg string) (string, error) {
return version(ctx, pkg)
}
// Verify interface compliance at compile time.
var _ snack.Manager = (*DNF)(nil)

178
dnf/dnf_linux.go Normal file
View File

@@ -0,0 +1,178 @@
//go:build linux
package dnf
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/gogrlx/snack"
)
func available() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// buildArgs constructs the command name and argument list from the base args
// and the provided options.
func buildArgs(baseArgs []string, opts snack.Options) (string, []string) {
cmd := "dnf"
args := make([]string, 0, len(baseArgs)+4)
if opts.Root != "" {
args = append(args, "--installroot="+opts.Root)
}
args = append(args, baseArgs...)
if opts.AssumeYes {
args = append(args, "-y")
}
if opts.DryRun {
args = append(args, "--setopt=tsflags=test")
}
if opts.Sudo {
args = append([]string{cmd}, args...)
cmd = "sudo"
}
return cmd, args
}
func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, error) {
cmd, args := buildArgs(baseArgs, opts)
c := exec.CommandContext(ctx, cmd, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
if err != nil {
se := stderr.String()
if strings.Contains(se, "permission denied") || strings.Contains(se, "requires root") ||
strings.Contains(se, "This command has to be run with superuser privileges") {
return "", fmt.Errorf("dnf: %w", snack.ErrPermissionDenied)
}
return "", fmt.Errorf("dnf: %s: %w", strings.TrimSpace(se), err)
}
return stdout.String(), nil
}
// formatTargets converts targets to dnf CLI arguments.
// DNF uses "pkg-version" for version pinning.
func formatTargets(targets []snack.Target) []string {
args := make([]string, 0, len(targets))
for _, t := range targets {
if t.Source != "" {
args = append(args, t.Source)
} else if t.Version != "" {
args = append(args, t.Name+"-"+t.Version)
} else {
args = append(args, t.Name)
}
}
return args
}
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
base := []string{"install", "-y"}
if o.Refresh {
base = append(base, "--refresh")
}
if o.FromRepo != "" {
base = append(base, "--repo="+o.FromRepo)
}
if o.Reinstall {
base[0] = "reinstall"
}
for _, t := range pkgs {
if t.FromRepo != "" {
base = append(base, "--repo="+t.FromRepo)
break
}
}
args := append(base, formatTargets(pkgs)...)
_, err := run(ctx, args, o)
return err
}
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
args := append([]string{"remove", "-y"}, snack.TargetNames(pkgs)...)
_, err := run(ctx, args, o)
return err
}
func upgrade(ctx context.Context, opts ...snack.Option) error {
o := snack.ApplyOptions(opts...)
_, err := run(ctx, []string{"upgrade", "-y"}, o)
return err
}
func update(ctx context.Context) error {
_, err := run(ctx, []string{"makecache"}, snack.Options{})
return err
}
func list(ctx context.Context) ([]snack.Package, error) {
out, err := run(ctx, []string{"list", "installed"}, snack.Options{})
if err != nil {
return nil, fmt.Errorf("dnf list: %w", err)
}
return parseList(out), nil
}
func search(ctx context.Context, query string) ([]snack.Package, error) {
out, err := run(ctx, []string{"search", query}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, nil
}
return nil, fmt.Errorf("dnf search: %w", err)
}
return parseSearch(out), nil
}
func info(ctx context.Context, pkg string) (*snack.Package, error) {
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
}
return nil, fmt.Errorf("dnf info: %w", err)
}
p := parseInfo(out)
if p == nil {
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
}
return p, nil
}
func isInstalled(ctx context.Context, pkg string) (bool, error) {
c := exec.CommandContext(ctx, "dnf", "list", "installed", pkg)
err := c.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("dnf isInstalled: %w", err)
}
return true, nil
}
func version(ctx context.Context, pkg string) (string, error) {
out, err := run(ctx, []string{"list", "installed", pkg}, snack.Options{})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
}
return "", fmt.Errorf("dnf version: %w", err)
}
pkgs := parseList(out)
if len(pkgs) == 0 {
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
}
return pkgs[0].Version, nil
}

47
dnf/dnf_other.go Normal file
View File

@@ -0,0 +1,47 @@
//go:build !linux
package dnf
import (
"context"
"github.com/gogrlx/snack"
)
func available() bool { return false }
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func upgrade(_ context.Context, _ ...snack.Option) error {
return snack.ErrUnsupportedPlatform
}
func update(_ context.Context) error {
return snack.ErrUnsupportedPlatform
}
func list(_ context.Context) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func search(_ context.Context, _ string) ([]snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func info(_ context.Context, _ string) (*snack.Package, error) {
return nil, snack.ErrUnsupportedPlatform
}
func isInstalled(_ context.Context, _ string) (bool, error) {
return false, snack.ErrUnsupportedPlatform
}
func version(_ context.Context, _ string) (string, error) {
return "", snack.ErrUnsupportedPlatform
}

297
dnf/parse.go Normal file
View File

@@ -0,0 +1,297 @@
package dnf
import (
"strings"
"github.com/gogrlx/snack"
)
// knownArchs is the set of RPM architecture suffixes.
var knownArchs = map[string]bool{
"x86_64": true,
"i686": true,
"i386": true,
"aarch64": true,
"armv7hl": true,
"ppc64le": true,
"s390x": true,
"noarch": true,
"src": true,
}
// normalizeName strips architecture suffixes from a package name.
func normalizeName(name string) string {
n, _ := parseArch(name)
return n
}
// parseArch extracts the architecture from a package name if present.
// Returns the name without arch and the arch string.
func parseArch(name string) (string, string) {
idx := strings.LastIndex(name, ".")
if idx < 0 {
return name, ""
}
arch := name[idx+1:]
if knownArchs[arch] {
return name[:idx], arch
}
return name, ""
}
// parseList parses the output of `dnf list installed` or `dnf list upgrades`.
// Format (after header line):
//
// pkg-name.arch version-release repo
func parseList(output string) []snack.Package {
var pkgs []snack.Package
inBody := false
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip header lines until we see a line of dashes or the first package
if !inBody {
if strings.HasPrefix(line, "Installed Packages") ||
strings.HasPrefix(line, "Available Upgrades") ||
strings.HasPrefix(line, "Available Packages") ||
strings.HasPrefix(line, "Upgraded Packages") {
inBody = true
continue
}
// Also skip metadata lines
if strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Updating") {
continue
}
// If we see a line with fields that looks like a package, process it
parts := strings.Fields(line)
if len(parts) >= 2 && strings.Contains(parts[0], ".") {
inBody = true
// fall through to process
} else {
continue
}
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
nameArch := parts[0]
ver := parts[1]
repo := ""
if len(parts) >= 3 {
repo = parts[2]
}
name, arch := parseArch(nameArch)
pkgs = append(pkgs, snack.Package{
Name: name,
Version: ver,
Arch: arch,
Repository: repo,
Installed: true,
})
}
return pkgs
}
// parseSearch parses the output of `dnf search`.
// Format:
//
// === Name Exactly Matched: query ===
// pkg-name.arch : Description text
// === Name & Summary Matched: query ===
// pkg-name.arch : Description text
func parseSearch(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "===") || strings.HasPrefix(line, "Last metadata") {
continue
}
// "pkg-name.arch : Description"
idx := strings.Index(line, " : ")
if idx < 0 {
continue
}
nameArch := strings.TrimSpace(line[:idx])
desc := strings.TrimSpace(line[idx+3:])
name, arch := parseArch(nameArch)
pkgs = append(pkgs, snack.Package{
Name: name,
Arch: arch,
Description: desc,
})
}
return pkgs
}
// parseInfo parses the output of `dnf info`.
// Format is "Key : Value" lines.
func parseInfo(output string) *snack.Package {
pkg := &snack.Package{}
for _, line := range strings.Split(output, "\n") {
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
switch key {
case "Name":
pkg.Name = val
case "Version":
pkg.Version = val
case "Release":
if pkg.Version != "" {
pkg.Version = pkg.Version + "-" + val
}
case "Architecture", "Arch":
pkg.Arch = val
case "Summary":
pkg.Description = val
case "From repo", "Repository":
pkg.Repository = val
}
}
if pkg.Name == "" {
return nil
}
return pkg
}
// parseVersionLock parses `dnf versionlock list` output.
// Lines are typically package NEVRA patterns like "pkg-0:1.2.3-4.el9.*"
func parseVersionLock(output string) []snack.Package {
var pkgs []snack.Package
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Adding") {
continue
}
// Try to extract name from NEVRA pattern
// Format: "name-epoch:version-release.arch" or simpler variants
name := line
// Strip trailing .* glob
name = strings.TrimSuffix(name, ".*")
// Strip arch
name, _ = parseArch(name)
// Strip version-release: find epoch pattern like "-0:" or last "-" before version
// Try epoch pattern first: "name-epoch:version-release"
for {
idx := strings.LastIndex(name, "-")
if idx <= 0 {
break
}
rest := name[idx+1:]
// Check if what follows looks like a version or epoch (digit or epoch:)
if len(rest) > 0 && (rest[0] >= '0' && rest[0] <= '9') {
name = name[:idx]
} else {
break
}
}
if name != "" {
pkgs = append(pkgs, snack.Package{Name: name, Installed: true})
}
}
return pkgs
}
// parseRepoList parses `dnf repolist --all` output.
// Format:
//
// repo id repo name status
// appstream CentOS Stream 9 - AppStream enabled
func parseRepoList(output string) []snack.Repository {
var repos []snack.Repository
inBody := false
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !inBody {
if strings.HasPrefix(line, "repo id") {
inBody = true
continue
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
id := parts[0]
enabled := false
status := parts[len(parts)-1]
if status == "enabled" {
enabled = true
}
// Name is everything between id and status
name := strings.Join(parts[1:len(parts)-1], " ")
repos = append(repos, snack.Repository{
ID: id,
Name: name,
Enabled: enabled,
})
}
return repos
}
// parseGroupList parses `dnf group list` output.
// Groups are listed under section headers like "Available Groups:" / "Installed Groups:".
func parseGroupList(output string) []string {
var groups []string
inSection := false
for _, line := range strings.Split(output, "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "Groups:") ||
strings.HasSuffix(strings.TrimSpace(line), "groups:") {
inSection = true
continue
}
if !inSection {
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
inSection = false
continue
}
groups = append(groups, trimmed)
}
return groups
}
// parseGroupInfo parses `dnf group info <group>` output.
// Packages are listed under section headers like "Mandatory Packages:", "Default Packages:", "Optional Packages:".
func parseGroupInfo(output string) []snack.Package {
var pkgs []snack.Package
inPkgSection := false
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
inPkgSection = false
continue
}
if strings.HasSuffix(trimmed, "Packages:") || strings.HasSuffix(trimmed, "packages:") {
inPkgSection = true
continue
}
if !inPkgSection {
continue
}
name := trimmed
// Strip leading marks like "=" or "-" or "+"
if len(name) > 2 && (name[0] == '=' || name[0] == '-' || name[0] == '+') && name[1] == ' ' {
name = name[2:]
}
name = strings.TrimSpace(name)
if name != "" {
pkgs = append(pkgs, snack.Package{Name: name})
}
}
return pkgs
}

234
dnf/parse_test.go Normal file
View File

@@ -0,0 +1,234 @@
package dnf
import (
"testing"
"github.com/gogrlx/snack"
)
func TestParseList(t *testing.T) {
input := `Last metadata expiration check: 0:42:03 ago on Wed 26 Feb 2025 10:00:00 AM UTC.
Installed Packages
acl.x86_64 2.3.1-4.el9 @anaconda
bash.x86_64 5.1.8-6.el9 @anaconda
curl.x86_64 7.76.1-23.el9 @baseos
`
pkgs := parseList(input)
if len(pkgs) != 3 {
t.Fatalf("expected 3 packages, got %d", len(pkgs))
}
tests := []struct {
name, ver, arch, repo string
}{
{"acl", "2.3.1-4.el9", "x86_64", "@anaconda"},
{"bash", "5.1.8-6.el9", "x86_64", "@anaconda"},
{"curl", "7.76.1-23.el9", "x86_64", "@baseos"},
}
for i, tt := range tests {
if pkgs[i].Name != tt.name {
t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, tt.name)
}
if pkgs[i].Version != tt.ver {
t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, tt.ver)
}
if pkgs[i].Arch != tt.arch {
t.Errorf("pkg[%d].Arch = %q, want %q", i, pkgs[i].Arch, tt.arch)
}
if pkgs[i].Repository != tt.repo {
t.Errorf("pkg[%d].Repository = %q, want %q", i, pkgs[i].Repository, tt.repo)
}
}
}
func TestParseListUpgrades(t *testing.T) {
input := `Available Upgrades
curl.x86_64 7.76.1-26.el9 baseos
vim-minimal.x86_64 2:9.0.1572-1.el9 appstream
`
pkgs := parseList(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "curl" || pkgs[0].Version != "7.76.1-26.el9" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
}
func TestParseSearch(t *testing.T) {
input := `Last metadata expiration check: 0:10:00 ago.
=== Name Exactly Matched: nginx ===
nginx.x86_64 : A high performance web server and reverse proxy server
=== Name & Summary Matched: nginx ===
nginx-mod-http-perl.x86_64 : Nginx HTTP perl module
`
pkgs := parseSearch(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" || pkgs[0].Arch != "x86_64" {
t.Errorf("unexpected first package: %+v", pkgs[0])
}
if pkgs[0].Description != "A high performance web server and reverse proxy server" {
t.Errorf("unexpected description: %q", pkgs[0].Description)
}
}
func TestParseInfo(t *testing.T) {
input := `Last metadata expiration check: 0:10:00 ago.
Available Packages
Name : nginx
Version : 1.20.1
Release : 14.el9_2.1
Architecture : x86_64
Size : 45 k
Source : nginx-1.20.1-14.el9_2.1.src.rpm
Repository : appstream
Summary : A high performance web server
License : BSD
Description : Nginx is a web server.
`
p := parseInfo(input)
if p == nil {
t.Fatal("expected package, got nil")
}
if p.Name != "nginx" {
t.Errorf("Name = %q, want nginx", p.Name)
}
if p.Version != "1.20.1-14.el9_2.1" {
t.Errorf("Version = %q, want 1.20.1-14.el9_2.1", p.Version)
}
if p.Arch != "x86_64" {
t.Errorf("Arch = %q, want x86_64", p.Arch)
}
if p.Repository != "appstream" {
t.Errorf("Repository = %q, want appstream", p.Repository)
}
}
func TestParseVersionLock(t *testing.T) {
input := `Last metadata expiration check: 0:05:00 ago.
nginx-0:1.20.1-14.el9_2.1.*
curl-0:7.76.1-23.el9.*
`
pkgs := parseVersionLock(input)
if len(pkgs) != 2 {
t.Fatalf("expected 2 packages, got %d", len(pkgs))
}
if pkgs[0].Name != "nginx" {
t.Errorf("pkg[0].Name = %q, want nginx", pkgs[0].Name)
}
if pkgs[1].Name != "curl" {
t.Errorf("pkg[1].Name = %q, want curl", pkgs[1].Name)
}
}
func TestParseRepoList(t *testing.T) {
input := `repo id repo name status
appstream CentOS Stream 9 - AppStream enabled
baseos CentOS Stream 9 - BaseOS enabled
crb CentOS Stream 9 - CRB disabled
`
repos := parseRepoList(input)
if len(repos) != 3 {
t.Fatalf("expected 3 repos, got %d", len(repos))
}
if repos[0].ID != "appstream" || !repos[0].Enabled {
t.Errorf("unexpected repo[0]: %+v", repos[0])
}
if repos[2].ID != "crb" || repos[2].Enabled {
t.Errorf("unexpected repo[2]: %+v", repos[2])
}
}
func TestParseGroupList(t *testing.T) {
input := `Available Groups:
Container Management
Development Tools
Headless Management
Installed Groups:
Minimal Install
`
groups := parseGroupList(input)
if len(groups) != 4 {
t.Fatalf("expected 4 groups, got %d", len(groups))
}
if groups[0] != "Container Management" {
t.Errorf("groups[0] = %q, want Container Management", groups[0])
}
}
func TestParseGroupInfo(t *testing.T) {
input := `Group: Development Tools
Description: A basic development environment.
Mandatory Packages:
autoconf
automake
gcc
Default Packages:
byacc
flex
Optional Packages:
ElectricFence
`
pkgs := parseGroupInfo(input)
if len(pkgs) != 6 {
t.Fatalf("expected 6 packages, got %d", len(pkgs))
}
names := make(map[string]bool)
for _, p := range pkgs {
names[p.Name] = true
}
for _, want := range []string{"autoconf", "automake", "gcc", "byacc", "flex", "ElectricFence"} {
if !names[want] {
t.Errorf("missing package %q", want)
}
}
}
func TestNormalizeName(t *testing.T) {
tests := []struct {
input, want string
}{
{"nginx.x86_64", "nginx"},
{"curl.aarch64", "curl"},
{"bash.noarch", "bash"},
{"python3", "python3"},
{"glibc.i686", "glibc"},
}
for _, tt := range tests {
got := normalizeName(tt.input)
if got != tt.want {
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseArch(t *testing.T) {
tests := []struct {
input, wantName, wantArch string
}{
{"nginx.x86_64", "nginx", "x86_64"},
{"curl.aarch64", "curl", "aarch64"},
{"bash", "bash", ""},
{"python3.11.noarch", "python3.11", "noarch"},
}
for _, tt := range tests {
name, arch := parseArch(tt.input)
if name != tt.wantName || arch != tt.wantArch {
t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
}
}
}
// Ensure interface checks from capabilities.go are satisfied.
var (
_ snack.Manager = (*DNF)(nil)
_ snack.VersionQuerier = (*DNF)(nil)
_ snack.Holder = (*DNF)(nil)
_ snack.Cleaner = (*DNF)(nil)
_ snack.FileOwner = (*DNF)(nil)
_ snack.RepoManager = (*DNF)(nil)
_ snack.KeyManager = (*DNF)(nil)
_ snack.Grouper = (*DNF)(nil)
_ snack.NameNormalizer = (*DNF)(nil)
)