diff --git a/dnf/capabilities.go b/dnf/capabilities.go new file mode 100644 index 0000000..83faa81 --- /dev/null +++ b/dnf/capabilities.go @@ -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) +} diff --git a/dnf/capabilities_linux.go b/dnf/capabilities_linux.go new file mode 100644 index 0000000..1974751 --- /dev/null +++ b/dnf/capabilities_linux.go @@ -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 +} diff --git a/dnf/capabilities_other.go b/dnf/capabilities_other.go new file mode 100644 index 0000000..6cc33a1 --- /dev/null +++ b/dnf/capabilities_other.go @@ -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 +} diff --git a/dnf/dnf.go b/dnf/dnf.go index 59871e6..b864d03 100644 --- a/dnf/dnf.go +++ b/dnf/dnf.go @@ -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) diff --git a/dnf/dnf_linux.go b/dnf/dnf_linux.go new file mode 100644 index 0000000..401e8ca --- /dev/null +++ b/dnf/dnf_linux.go @@ -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 +} diff --git a/dnf/dnf_other.go b/dnf/dnf_other.go new file mode 100644 index 0000000..2592a26 --- /dev/null +++ b/dnf/dnf_other.go @@ -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 +} diff --git a/dnf/parse.go b/dnf/parse.go new file mode 100644 index 0000000..9d66378 --- /dev/null +++ b/dnf/parse.go @@ -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 ` 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 +} diff --git a/dnf/parse_test.go b/dnf/parse_test.go new file mode 100644 index 0000000..3aeb7d9 --- /dev/null +++ b/dnf/parse_test.go @@ -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) +) diff --git a/rpm/capabilities.go b/rpm/capabilities.go new file mode 100644 index 0000000..0f820b6 --- /dev/null +++ b/rpm/capabilities.go @@ -0,0 +1,33 @@ +package rpm + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// Compile-time interface checks. +var ( + _ snack.FileOwner = (*RPM)(nil) + _ snack.NameNormalizer = (*RPM)(nil) +) + +// FileList returns all files installed by a package. +func (r *RPM) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (r *RPM) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} + +// NormalizeName returns the canonical form of a package name. +func (r *RPM) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (r *RPM) ParseArch(name string) (string, string) { + return parseArchSuffix(name) +} diff --git a/rpm/capabilities_other.go b/rpm/capabilities_other.go new file mode 100644 index 0000000..52ee77d --- /dev/null +++ b/rpm/capabilities_other.go @@ -0,0 +1,3 @@ +//go:build !linux + +package rpm diff --git a/rpm/parse.go b/rpm/parse.go new file mode 100644 index 0000000..5ec438d --- /dev/null +++ b/rpm/parse.go @@ -0,0 +1,97 @@ +package rpm + +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, _ := parseArchSuffix(name) + return n +} + +// parseArchSuffix extracts the architecture from a package name if present. +func parseArchSuffix(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 rpm -qa --queryformat output. +// Format: "NAME\tVERSION-RELEASE\tSUMMARY\n" +func parseList(output string) []snack.Package { + var pkgs []snack.Package + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + pkg := snack.Package{ + Name: parts[0], + Version: parts[1], + Installed: true, + } + if len(parts) >= 3 { + pkg.Description = parts[2] + } + pkgs = append(pkgs, pkg) + } + return pkgs +} + +// parseInfo parses rpm -qi output. +// 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 + } + } + if pkg.Name == "" { + return nil + } + return pkg +} diff --git a/rpm/parse_test.go b/rpm/parse_test.go new file mode 100644 index 0000000..2661ace --- /dev/null +++ b/rpm/parse_test.go @@ -0,0 +1,103 @@ +package rpm + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList(t *testing.T) { + input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n" + pkgs := parseList(input) + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" { + t.Errorf("unexpected pkg[0]: %+v", pkgs[0]) + } + if pkgs[0].Description != "The GNU Bourne Again shell" { + t.Errorf("unexpected description: %q", pkgs[0].Description) + } + if !pkgs[0].Installed { + t.Error("expected Installed=true") + } +} + +func TestParseInfo(t *testing.T) { + input := `Name : bash +Version : 5.1.8 +Release : 6.el9 +Architecture: x86_64 +Install Date: Mon 01 Jan 2024 12:00:00 AM UTC +Group : System Environment/Shells +Size : 7896043 +License : GPLv3+ +Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123 +Source RPM : bash-5.1.8-6.el9.src.rpm +Build Date : Mon 01 Jan 2024 12:00:00 AM UTC +Build Host : builder.example.com +Packager : CentOS Buildsys +Vendor : CentOS +URL : https://www.gnu.org/software/bash +Summary : The GNU Bourne Again shell +Description : +The GNU Bourne Again shell (Bash) is a shell or command language +interpreter that is compatible with the Bourne shell (sh). +` + p := parseInfo(input) + if p == nil { + t.Fatal("expected package, got nil") + } + if p.Name != "bash" { + t.Errorf("Name = %q, want bash", p.Name) + } + if p.Version != "5.1.8-6.el9" { + t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version) + } + if p.Arch != "x86_64" { + t.Errorf("Arch = %q, want x86_64", p.Arch) + } + if p.Description != "The GNU Bourne Again shell" { + t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description) + } +} + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input, want string + }{ + {"nginx.x86_64", "nginx"}, + {"curl.aarch64", "curl"}, + {"bash.noarch", "bash"}, + {"python3", "python3"}, + } + 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 TestParseArchSuffix(t *testing.T) { + tests := []struct { + input, wantName, wantArch string + }{ + {"nginx.x86_64", "nginx", "x86_64"}, + {"bash", "bash", ""}, + {"glibc.i686", "glibc", "i686"}, + } + for _, tt := range tests { + name, arch := parseArchSuffix(tt.input) + if name != tt.wantName || arch != tt.wantArch { + t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch) + } + } +} + +// Compile-time interface checks. +var ( + _ snack.Manager = (*RPM)(nil) + _ snack.FileOwner = (*RPM)(nil) + _ snack.NameNormalizer = (*RPM)(nil) +) diff --git a/rpm/rpm.go b/rpm/rpm.go index f412890..babb36b 100644 --- a/rpm/rpm.go +++ b/rpm/rpm.go @@ -1,2 +1,85 @@ -// Package rpm provides Go bindings for RPM (low-level Red Hat package tool). +// Package rpm provides Go bindings for the rpm low-level package manager. package rpm + +import ( + "context" + + "github.com/gogrlx/snack" +) + +// RPM wraps the rpm package manager CLI. +type RPM struct { + snack.Locker +} + +// New returns a new RPM manager. +func New() *RPM { + return &RPM{} +} + +// Name returns "rpm". +func (r *RPM) Name() string { return "rpm" } + +// Available reports whether rpm is present on the system. +func (r *RPM) Available() bool { return available() } + +// Install one or more packages. +func (r *RPM) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + r.Lock() + defer r.Unlock() + return install(ctx, pkgs, opts...) +} + +// Remove one or more packages. +func (r *RPM) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + r.Lock() + defer r.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Purge removes packages (same as Remove for rpm). +func (r *RPM) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + r.Lock() + defer r.Unlock() + return remove(ctx, pkgs, opts...) +} + +// Upgrade upgrades packages from files. +func (r *RPM) Upgrade(ctx context.Context, opts ...snack.Option) error { + r.Lock() + defer r.Unlock() + return upgradeAll(ctx, opts...) +} + +// Update is not supported by rpm (use dnf instead). +func (r *RPM) Update(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +// List returns all installed packages. +func (r *RPM) List(ctx context.Context) ([]snack.Package, error) { + return list(ctx) +} + +// Search queries installed packages matching the query. +func (r *RPM) Search(ctx context.Context, query string) ([]snack.Package, error) { + return search(ctx, query) +} + +// Info returns details about a specific package. +func (r *RPM) Info(ctx context.Context, pkg string) (*snack.Package, error) { + return info(ctx, pkg) +} + +// IsInstalled reports whether a package is currently installed. +func (r *RPM) IsInstalled(ctx context.Context, pkg string) (bool, error) { + return isInstalled(ctx, pkg) +} + +// Version returns the installed version of a package. +func (r *RPM) Version(ctx context.Context, pkg string) (string, error) { + return version(ctx, pkg) +} + +// Verify interface compliance at compile time. +var _ snack.Manager = (*RPM)(nil) diff --git a/rpm/rpm_linux.go b/rpm/rpm_linux.go new file mode 100644 index 0000000..20604a3 --- /dev/null +++ b/rpm/rpm_linux.go @@ -0,0 +1,182 @@ +//go:build linux + +package rpm + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func available() bool { + _, err := exec.LookPath("rpm") + return err == nil +} + +func run(ctx context.Context, args []string) (string, error) { + c := exec.CommandContext(ctx, "rpm", 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") { + return "", fmt.Errorf("rpm: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("rpm: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +func runWithSudo(ctx context.Context, args []string, sudo bool) (string, error) { + cmd := "rpm" + if sudo { + args = append([]string{cmd}, args...) + cmd = "sudo" + } + 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") { + return "", fmt.Errorf("rpm: %w", snack.ErrPermissionDenied) + } + return "", fmt.Errorf("rpm: %s: %w", strings.TrimSpace(se), err) + } + return stdout.String(), nil +} + +// formatSources extracts source paths or names from targets for rpm -i/-U. +func formatSources(targets []snack.Target) []string { + args := make([]string, 0, len(targets)) + for _, t := range targets { + if t.Source != "" { + args = append(args, t.Source) + } else { + args = append(args, t.Name) + } + } + return args +} + +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + args := append([]string{"-i"}, formatSources(pkgs)...) + _, err := runWithSudo(ctx, args, o.Sudo) + return err +} + +func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + o := snack.ApplyOptions(opts...) + args := append([]string{"-e"}, snack.TargetNames(pkgs)...) + _, err := runWithSudo(ctx, args, o.Sudo) + return err +} + +func upgradeAll(ctx context.Context, opts ...snack.Option) error { + // rpm -U requires specific files; upgradeAll with no targets is a no-op + _ = opts + return fmt.Errorf("rpm: upgrade requires specific package files: %w", snack.ErrUnsupportedPlatform) +} + +func list(ctx context.Context) ([]snack.Package, error) { + out, err := run(ctx, []string{"-qa", "--queryformat", `%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n`}) + if err != nil { + return nil, fmt.Errorf("rpm list: %w", err) + } + return parseList(out), nil +} + +func search(ctx context.Context, query string) ([]snack.Package, error) { + out, err := run(ctx, []string{"-qa", "*" + query + "*", "--queryformat", `%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n`}) + if err != nil { + // exit 1 = no matches + if strings.Contains(err.Error(), "exit status 1") { + return nil, nil + } + return nil, fmt.Errorf("rpm search: %w", err) + } + return parseList(out), nil +} + +func info(ctx context.Context, pkg string) (*snack.Package, error) { + out, err := run(ctx, []string{"-qi", pkg}) + if err != nil { + if strings.Contains(err.Error(), "is not installed") || + strings.Contains(err.Error(), "exit status 1") { + return nil, fmt.Errorf("rpm info %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("rpm info: %w", err) + } + p := parseInfo(out) + if p == nil { + return nil, fmt.Errorf("rpm info %s: %w", pkg, snack.ErrNotInstalled) + } + p.Installed = true + return p, nil +} + +func isInstalled(ctx context.Context, pkg string) (bool, error) { + c := exec.CommandContext(ctx, "rpm", "-q", pkg) + err := c.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("rpm isInstalled: %w", err) + } + return true, nil +} + +func version(ctx context.Context, pkg string) (string, error) { + c := exec.CommandContext(ctx, "rpm", "-q", "--queryformat", "%{VERSION}-%{RELEASE}", 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") || strings.Contains(err.Error(), "exit status 1") { + return "", fmt.Errorf("rpm version %s: %w", pkg, snack.ErrNotInstalled) + } + return "", fmt.Errorf("rpm version: %w", err) + } + return strings.TrimSpace(stdout.String()), nil +} + +func fileList(ctx context.Context, pkg string) ([]string, error) { + out, err := run(ctx, []string{"-ql", pkg}) + if err != nil { + if strings.Contains(err.Error(), "is not installed") { + return nil, fmt.Errorf("rpm fileList %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("rpm fileList: %w", err) + } + var files []string + for _, line := range strings.Split(out, "\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) { + out, err := run(ctx, []string{"-qf", path}) + if err != nil { + if strings.Contains(err.Error(), "is not owned by any package") { + return "", fmt.Errorf("rpm owner %s: %w", path, snack.ErrNotFound) + } + return "", fmt.Errorf("rpm owner: %w", err) + } + return strings.TrimSpace(out), nil +} diff --git a/rpm/rpm_other.go b/rpm/rpm_other.go new file mode 100644 index 0000000..63723d2 --- /dev/null +++ b/rpm/rpm_other.go @@ -0,0 +1,51 @@ +//go:build !linux + +package rpm + +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 upgradeAll(_ context.Context, _ ...snack.Option) 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 +} + +func fileList(_ context.Context, _ string) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func owner(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +}