From 03f319e0144894c2c05640a352dc918c491bfc59 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Tue, 30 Jun 2026 09:53:16 -0500 Subject: [PATCH] first commit --- LICENSE | 19 ++ README.md | 141 +++++++++++++++ apk.go | 405 +++++++++++++++++++++++++++++++++++++++++++ apt-get.go | 118 +++++++++++++ apt.go | 113 ++++++++++++ apt_common.go | 163 ++++++++++++++++++ aur.go | 156 +++++++++++++++++ brew.go | 273 +++++++++++++++++++++++++++++ dnf.go | 101 +++++++++++ go.mod | 15 ++ go.sum | 18 ++ helpers.go | 334 ++++++++++++++++++++++++++++++++++++ manager_test.go | 447 ++++++++++++++++++++++++++++++++++++++++++++++++ managers.go | 165 ++++++++++++++++++ pacman.go | 362 +++++++++++++++++++++++++++++++++++++++ privilege.go | 92 ++++++++++ rpm_common.go | 158 +++++++++++++++++ yum.go | 101 +++++++++++ zypper.go | 160 +++++++++++++++++ 19 files changed, 3341 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apk.go create mode 100644 apt-get.go create mode 100644 apt.go create mode 100644 apt_common.go create mode 100644 aur.go create mode 100644 brew.go create mode 100644 dnf.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helpers.go create mode 100644 manager_test.go create mode 100644 managers.go create mode 100644 pacman.go create mode 100644 privilege.go create mode 100644 rpm_common.go create mode 100644 yum.go create mode 100644 zypper.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83d4a90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c876545 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# go-package-manager + +[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/go-package-manager.svg)](https://pkg.go.dev/github.com/grmrgecko/go-package-manager) + +`pkgmgr` is a Go library that abstracts system package managers behind a single +[`Manager`](managers.go) interface. One code path can install, upgrade, search, +and manage repositories on Debian, Fedora, openSUSE, Arch, Alpine, macOS, and +more, without each caller hard-coding `apt`/`dnf`/`pacman` invocations. + +API documentation is available on [pkg.go.dev](https://pkg.go.dev/github.com/grmrgecko/go-package-manager). + +## Supported managers + +| Manager | Type | Format | Notes | +| --------- | --------- | ------- | ------------------------------------------------ | +| zypper | root | rpm | openSUSE | +| dnf | root | rpm | Fedora / RHEL | +| yum | root | rpm | Legacy RHEL | +| apt | root | deb | Debian / Ubuntu | +| apt-get | root | deb | Debian / Ubuntu | +| pacman | root | pacman | Arch | +| apk | root | apk | Alpine | +| brew | unpriv. | bottle | Homebrew; refuses to run as root | +| yay | unpriv. | pacman | AUR helper; pacman drop-in | +| paru | unpriv. | pacman | AUR helper; pacman drop-in | + +Root-type managers escalate privileged commands through a configurable wrapper +(`sudo` by default). Homebrew and the AUR helpers must not run as root, so they +bypass the wrapper and instead drop to an unprivileged user via `sudo -u` when +the process is running as root. + +## Installation + +```bash +go get github.com/grmrgecko/go-package-manager +``` + +## Usage + +Resolve the active system manager, then drive it through the interface. The +example below installs a package, escalating with `sudo` when not already root. + +```go +package main + +import ( + "context" + "log" + + pkgmgr "github.com/grmrgecko/go-package-manager" +) + +func main() { + m := pkgmgr.GetSystemManager() + if m == nil { + log.Fatal("no supported package manager found") + } + + // Wrap privileged commands with sudo when not running as root. + m.UseSudoWhenNeeded() + + ctx := context.Background() + if err := m.Sync(ctx, nil); err != nil { + log.Fatal(err) + } + if err := m.Install(ctx, nil, "htop"); err != nil { + log.Fatal(err) + } + + results, err := m.Search(ctx, nil, "editor") + if err != nil { + log.Fatal(err) + } + for _, r := range results { + log.Printf("%s %s - %s", r.Name, r.Version, r.Summary) + } +} +``` + +### Selecting a manager explicitly + +`GetSystemManager` auto-detects in priority order. To target a specific manager, +construct it directly: + +```go +m := &pkgmgr.Apt{} +m.UseSudoWhenNeeded() +``` + +AUR helpers must be constructed through their constructors so the helper binary +name is set: + +```go +yay := pkgmgr.NewYay() +paru := pkgmgr.NewParu() +``` + +## Interface + +The `Manager` interface covers the full package lifecycle. Methods that perform +I/O take a `context.Context` for cancellation and timeouts. + +- **Identity:** `Name`, `Format`, `Path` +- **Privilege:** `SetCmdWrapper`, `UseSudoWhenNeeded` +- **I/O:** `SetIO` (override stdin/stdout/stderr of spawned commands) +- **Repos:** `AddRepo`, `AddRepoURL`, `RemoveRepo`, `GetRepo`, `ListRepos` +- **Keys:** `AddRepoKey`, `AddRepoKeyFile`, `AddRepoKeyURL` +- **Packages:** `Sync`, `Install`, `Remove`, `Upgrade`, `InstallFile`, + `UpgradeAll`, `Clean` +- **Queries:** `Search`, `Info`, `ListInstalled`, `ListUpgradable` + +`Search` returns structured `SearchResult` values; `Info` returns the underlying +tool's native text output; `ListInstalled` and `ListUpgradable` return +`name -> version` maps. `ListRepos` returns every configured repo as a +`name -> configuration` map, where each value matches what `GetRepo` returns for +that name. + +## Privilege handling + +Two mechanisms cover opposite requirements: + +- **Root managers** (`apt`, `dnf`, `pacman`, ...) need root for mutating + operations. `UseSudoWhenNeeded` sets the wrapper to `sudo` when the process is + not already root. +- **Non-root managers** (`brew`, `yay`, `paru`) refuse to run as root. They use + an embedded `dropPrivilege` to run their command as an unprivileged user, + dropping via `sudo -u` only when the process is root. The drop user defaults to + `SUDO_USER` and can be set explicitly with `SetDropUser`. + +A custom command wrapper (for example `[]string{"sudo", "-n"}`) can be supplied +through `SetCmdWrapper`. + +## Testing + +```bash +go test ./... +``` + +The suite verifies that every manager satisfies the `Manager` interface and +exercises the command wrapper, I/O overrides, privilege drop logic, and the +output parsers. diff --git a/apk.go b/apk.go new file mode 100644 index 0000000..288561f --- /dev/null +++ b/apk.go @@ -0,0 +1,405 @@ +package pkgmgr + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +const ( + // APK_REPOSITORIES is the single file apk reads its repositories from. + // There is no drop-in directory, so named repos are delimited with marker + // comments so they can be located and removed later. + APK_REPOSITORIES = "/etc/apk/repositories" + // APK_KEYS_DIR is where apk reads repo signing keys from. + APK_KEYS_DIR = "/etc/apk/keys" +) + +// apkVersionSplit splits an "name-version" token into the package name and its +// version (pkgver plus the -rN pkgrel), which apk concatenates with hyphens. +var apkVersionSplit = regexp.MustCompile(`^(.+)-(\d[^-]*-r\d+)$`) + +type Apk struct { + baseManager +} + +// Name is the package manager's name. +func (p *Apk) Name() string { + return "apk" +} + +// Format is the package format the manager installs. +func (p *Apk) Format() string { + return "apk" +} + +// Path is the resolved path to the apk command, or "" if missing. +func (p *Apk) Path() string { + return findBinary("apk") +} + +// exec runs apk with the given args, failing if apk cannot be found. +func (p *Apk) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find apk") + } + return p.Command(ctx, bin, args...).Run() +} + +// AddRepo adds a repo from a configuration string (one repository URL per +// line). The lines are stored in APK_REPOSITORIES delimited by markers. +func (p *Apk) AddRepo(name, config string) error { + return setMarkerSection(APK_REPOSITORIES, name, config) +} + +// AddRepoURL adds repoURL as a repository line. apk repositories are plain +// URLs, so repoURL is used directly rather than downloaded. +func (p *Apk) AddRepoURL(ctx context.Context, name, repoURL string) error { + return setMarkerSection(APK_REPOSITORIES, name, repoURL) +} + +// RemoveRepo removes a repo's lines from APK_REPOSITORIES. +func (p *Apk) RemoveRepo(name string) error { + return removeMarkerSection(APK_REPOSITORIES, name) +} + +// GetRepo returns a repo's configuration, or "" if it does not exist. +func (p *Apk) GetRepo(name string) string { + return getMarkerSection(APK_REPOSITORIES, name) +} + +// ListRepos returns every named repo section in APK_REPOSITORIES mapped +// name->configuration. The value matches what GetRepo returns for that name. +// Plain repository lines that are not enclosed in named markers have no name +// and so are not included. +func (p *Apk) ListRepos(ctx context.Context, args []string) (map[string]string, error) { + return listMarkerSections(APK_REPOSITORIES) +} + +// apkKeyDest returns the key file path for a key, named from its contents so +// re-adding the same key is idempotent. +func apkKeyDest(key []byte) string { + sum := sha256.Sum256(key) + return filepath.Join(APK_KEYS_DIR, "pkgmgr-"+hex.EncodeToString(sum[:8])+".rsa.pub") +} + +// apkWriteKey installs a repo signing key into APK_KEYS_DIR. +func apkWriteKey(key []byte) error { + if err := os.MkdirAll(APK_KEYS_DIR, 0755); err != nil { + return err + } + return os.WriteFile(apkKeyDest(key), key, 0644) +} + +// AddRepoKey adds a key for repo package verification. +func (p *Apk) AddRepoKey(ctx context.Context, key string) error { + return apkWriteKey([]byte(key)) +} + +// AddRepoKeyFile adds a key for repo package verification from a file. +func (p *Apk) AddRepoKeyFile(ctx context.Context, keyFile string) error { + data, err := os.ReadFile(keyFile) + if err != nil { + return err + } + return apkWriteKey(data) +} + +// AddRepoKeyURL adds a key for repo package verification from a URL. +func (p *Apk) AddRepoKeyURL(ctx context.Context, keyURL string) error { + var buf strings.Builder + if err := download(ctx, keyURL, &buf); err != nil { + return err + } + return apkWriteKey([]byte(buf.String())) +} + +// Sync updates repository metadata. +func (p *Apk) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "update")...) +} + +// Install installs packages from the repositories. +func (p *Apk) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "add"), packages...)...) +} + +// Remove removes packages. +func (p *Apk) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "del"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Apk) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "add", "--upgrade"), packages...)...) +} + +// InstallFile installs a package from a local file. +func (p *Apk) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Apk) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes cached package data. It requires a configured apk cache. +func (p *Apk) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "cache", "clean")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. +func (p *Apk) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find apk") + } + + // `apk version -l '<'` lists installed packages older than the repositories + // offer, as "name-oldver < newver". + args = joinArgs(args, "version", "-l", "<") + return runParseList(p.Command(ctx, bin, args...), parseApkUpgradable) +} + +// parseApkUpgradable parses apk's "name-oldver < newver" rows into a +// name->candidate-version map. +func parseApkUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + // Skip the header and any malformed rows; data rows have the "<" marker. + if len(fields) < 3 || fields[1] != "<" { + continue + } + m := apkVersionSplit.FindStringSubmatch(fields[0]) + if m == nil { + continue + } + out[m[1]] = fields[2] + } + return out, scanner.Err() +} + +// Search searches the repositories for packages matching query. +func (p *Apk) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find apk") + } + // `apk search -v` prints "name-version - description" per match. + return runSearch(p.Command(ctx, bin, joinArgs(args, "search", "-v", query)...), parseApkSearch) +} + +// parseApkSearch parses apk's "name-version - description" lines into search +// results. +func parseApkSearch(r io.Reader) ([]SearchResult, error) { + var out []SearchResult + scanner := bufio.NewScanner(r) + for scanner.Scan() { + token, summary, _ := strings.Cut(scanner.Text(), " - ") + m := apkVersionSplit.FindStringSubmatch(strings.TrimSpace(token)) + if m == nil { + continue + } + out = append(out, SearchResult{Name: m[1], Version: m[2], Summary: strings.TrimSpace(summary)}) + } + return out, scanner.Err() +} + +// Info returns detailed information about the named packages. +func (p *Apk) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find apk") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "info"), packages...)...)) +} + +// ListInstalled returns installed packages mapped to their version. +func (p *Apk) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find apk") + } + + // `apk info -v` prints one "name-version" token per line. + args = joinArgs(args, "info", "-v") + cmd := p.Command(ctx, bin, args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + out, perr := parseApkList(stdout) + if werr := cmd.Wait(); werr != nil { + return nil, werr + } + if perr != nil { + return nil, perr + } + return out, nil +} + +// parseApkList parses "name-version" tokens from apk into a name->version map. +func parseApkList(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 { + continue + } + m := apkVersionSplit.FindStringSubmatch(fields[0]) + if m == nil { + continue + } + out[m[1]] = m[2] + } + return out, scanner.Err() +} + +// markerSection returns the start and end comment markers for a named section. +func markerSection(name string) (string, string) { + return "# >>> " + name, "# <<< " + name +} + +// getMarkerSection returns the body between a named section's markers, or "" +// when the section or file is absent. +func getMarkerSection(file, name string) string { + data, err := os.ReadFile(file) + if err != nil { + return "" + } + + start, end := markerSection(name) + var body []string + in, found := false, false + for _, line := range strings.Split(string(data), "\n") { + switch strings.TrimSpace(line) { + case start: + in, found = true, true + continue + case end: + in = false + continue + } + if in { + body = append(body, line) + } + } + if !found { + return "" + } + return strings.TrimSpace(strings.Join(body, "\n")) +} + +// listMarkerSections returns every named marker section in file mapped +// name->body. Each body matches getMarkerSection. A missing file yields an empty +// result. +func listMarkerSections(file string) (map[string]string, error) { + out := make(map[string]string) + data, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + + const startPrefix = "# >>> " + var name string + var body []string + flush := func() { + if name != "" { + out[name] = strings.TrimSpace(strings.Join(body, "\n")) + } + name, body = "", nil + } + for _, line := range strings.Split(string(data), "\n") { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, startPrefix): + flush() + name = strings.TrimPrefix(t, startPrefix) + case strings.HasPrefix(t, "# <<< "): + flush() + default: + if name != "" { + body = append(body, line) + } + } + } + flush() + return out, nil +} + +// removeMarkerSection removes a named section (markers and body) from file, +// treating a missing file as success. +func removeMarkerSection(file, name string) error { + data, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + start, end := markerSection(name) + var out []string + skip := false + for _, line := range strings.Split(string(data), "\n") { + switch strings.TrimSpace(line) { + case start: + skip = true + continue + case end: + skip = false + continue + } + if skip { + continue + } + out = append(out, line) + } + return os.WriteFile(file, []byte(strings.Join(out, "\n")), 0644) +} + +// setMarkerSection inserts or replaces a named section in file with body. +func setMarkerSection(file, name, body string) error { + if err := removeMarkerSection(file, name); err != nil { + return err + } + + data, err := os.ReadFile(file) + if err != nil && !os.IsNotExist(err) { + return err + } + + start, end := markerSection(name) + var b strings.Builder + existing := strings.TrimRight(string(data), "\n") + if existing != "" { + b.WriteString(existing) + b.WriteString("\n") + } + b.WriteString(start + "\n") + if body = strings.TrimRight(body, "\n"); body != "" { + b.WriteString(body + "\n") + } + b.WriteString(end + "\n") + return os.WriteFile(file, []byte(b.String()), 0644) +} diff --git a/apt-get.go b/apt-get.go new file mode 100644 index 0000000..332f9b2 --- /dev/null +++ b/apt-get.go @@ -0,0 +1,118 @@ +package pkgmgr + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +type AptGet struct { + aptBase +} + +// Name is the package manager's name. +func (p *AptGet) Name() string { + return "apt-get" +} + +// Format is the package format the manager installs. +func (p *AptGet) Format() string { + return "deb" +} + +// Path is the resolved path to the apt-get command, or "" if missing. +func (p *AptGet) Path() string { + return findBinary("apt-get") +} + +// exec runs apt-get with the given args, failing if apt-get cannot be found. +func (p *AptGet) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find apt-get") + } + return p.Command(ctx, bin, args...).Run() +} + +// Sync updates repository metadata. +func (p *AptGet) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "update")...) +} + +// Install installs packages from the repositories. +func (p *AptGet) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *AptGet) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *AptGet) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// InstallFile installs a package from a local file. +func (p *AptGet) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *AptGet) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes the local package cache. +func (p *AptGet) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "clean")...) +} + +// Search searches the repositories for packages matching query. apt-get has no +// search subcommand, so apt-cache is used. +func (p *AptGet) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + aptCache := findBinary("apt-cache") + if aptCache == "" { + return nil, fmt.Errorf("unable to find apt-cache") + } + return runSearch(p.Command(ctx, aptCache, joinArgs(args, "search", query)...), parseAptCacheSearch) +} + +// parseAptCacheSearch parses the "name - summary" lines printed by `apt-cache +// search` into search results. apt-cache does not report a version. +func parseAptCacheSearch(r io.Reader) ([]SearchResult, error) { + var out []SearchResult + scanner := bufio.NewScanner(r) + for scanner.Scan() { + name, summary, ok := strings.Cut(scanner.Text(), " - ") + if !ok { + continue + } + out = append(out, SearchResult{Name: strings.TrimSpace(name), Summary: strings.TrimSpace(summary)}) + } + return out, scanner.Err() +} + +// Info returns detailed information about the named packages. apt-get has no +// show subcommand, so apt-cache is used. +func (p *AptGet) Info(ctx context.Context, args []string, packages ...string) (string, error) { + aptCache := findBinary("apt-cache") + if aptCache == "" { + return "", fmt.Errorf("unable to find apt-cache") + } + return runCapture(p.Command(ctx, aptCache, append(joinArgs(args, "show"), packages...)...)) +} + +// ListInstalled returns installed packages mapped to their version. +func (p *AptGet) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + dpkgQuery := findBinary("dpkg-query") + if dpkgQuery == "" { + return nil, fmt.Errorf("unable to find dpkg-query") + } + + args = joinArgs(args, "-f", "${Package} := ${Version}\\n", "-W") + return runVersionList(p.Command(ctx, dpkgQuery, args...), " := ", false) +} diff --git a/apt.go b/apt.go new file mode 100644 index 0000000..e160026 --- /dev/null +++ b/apt.go @@ -0,0 +1,113 @@ +package pkgmgr + +import ( + "context" + "fmt" + "io" + "strings" +) + +type Apt struct { + aptBase +} + +// Name is the package manager's name. +func (p *Apt) Name() string { + return "apt" +} + +// Format is the package format the manager installs. +func (p *Apt) Format() string { + return "deb" +} + +// Path is the resolved path to the apt command, or "" if missing. +func (p *Apt) Path() string { + return findBinary("apt") +} + +// exec runs apt with the given args, failing if apt cannot be found. +func (p *Apt) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find apt") + } + return p.Command(ctx, bin, args...).Run() +} + +// Sync updates repository metadata. +func (p *Apt) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "update")...) +} + +// Install installs packages from the repositories. +func (p *Apt) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *Apt) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Apt) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// InstallFile installs a package from a local file. +func (p *Apt) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Apt) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes the local package cache. +func (p *Apt) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "clean")...) +} + +// Search searches the repositories for packages matching query. +func (p *Apt) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find apt") + } + return runSearch(p.Command(ctx, bin, joinArgs(args, "search", query)...), parseAptSearch) +} + +// parseAptSearch parses the "name/suite version arch" headers and indented +// descriptions printed by `apt search` into search results. +func parseAptSearch(r io.Reader) ([]SearchResult, error) { + return parseColumnarSearch(r, func(fields []string) (string, string, bool) { + // Header rows look like "name/suite version arch [tags]". + if len(fields) < 2 || !strings.Contains(fields[0], "/") { + return "", "", false + } + name, _, _ := strings.Cut(fields[0], "/") + return name, fields[1], true + }) +} + +// Info returns detailed information about the named packages. +func (p *Apt) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find apt") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "show"), packages...)...)) +} + +// ListInstalled returns installed packages mapped to their version. +func (p *Apt) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + dpkgQuery := findBinary("dpkg-query") + if dpkgQuery == "" { + return nil, fmt.Errorf("unable to find dpkg-query") + } + + args = joinArgs(args, "-f", "${Package} := ${Version}\\n", "-W") + return runVersionList(p.Command(ctx, dpkgQuery, args...), " := ", false) +} diff --git a/apt_common.go b/apt_common.go new file mode 100644 index 0000000..8c52d19 --- /dev/null +++ b/apt_common.go @@ -0,0 +1,163 @@ +package pkgmgr + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const ( + // APT_SOURCES_DIR is where apt repo files are written. + APT_SOURCES_DIR = "/etc/apt/sources.list.d" + // APT_KEYRINGS_DIR is where apt repo signing keys are written when apt-key + // is unavailable. It is only used as a fallback on modern systems (apt has + // removed apt-key), so the armored ".asc" form written here is always read + // by the apt that is present. + APT_KEYRINGS_DIR = "/etc/apt/trusted.gpg.d" +) + +// aptBase provides the repo and signing-key behavior shared by the apt and +// apt-get managers. Each concrete manager embeds it and supplies its own Name, +// Format, Path, and action subcommands. +type aptBase struct { + baseManager +} + +func (p *aptBase) repoPath(name string) string { + return filepath.Join(APT_SOURCES_DIR, name+".list") +} + +// AddRepo adds a repo from a configuration string. +func (p *aptBase) AddRepo(name, config string) error { + return writeRepoFile(p.repoPath(name), config) +} + +// AddRepoURL adds a repo whose configuration is downloaded from repoURL. +func (p *aptBase) AddRepoURL(ctx context.Context, name, repoURL string) error { + return downloadRepoFile(ctx, p.repoPath(name), repoURL) +} + +// RemoveRepo removes a repo. +func (p *aptBase) RemoveRepo(name string) error { + return removeRepoFile(p.repoPath(name)) +} + +// GetRepo returns a repo's configuration, or "" if it does not exist. +func (p *aptBase) GetRepo(name string) string { + return readRepoFile(p.repoPath(name)) +} + +// ListRepos returns every repo file in APT_SOURCES_DIR keyed by file stem. The +// value matches what GetRepo returns for that name. Only the .list drop-in files +// this manager reads and writes are enumerated; the main sources.list and +// deb822 .sources files are not included. +func (p *aptBase) ListRepos(ctx context.Context, args []string) (map[string]string, error) { + return listRepoFiles(APT_SOURCES_DIR, ".list") +} + +// AddRepoKey adds a key for repo package verification. apt-key is used when it +// is present, which keeps older releases (where it is the only supported +// method) working. On modern systems that have removed apt-key, a drop-in +// keyring file is written instead. +func (p *aptBase) AddRepoKey(ctx context.Context, key string) error { + if aptKey := findBinary("apt-key"); aptKey != "" { + return p.aptKeyAdd(ctx, aptKey, strings.NewReader(key)) + } + return aptWriteKey([]byte(key)) +} + +// AddRepoKeyFile adds a key for repo package verification from a file. +func (p *aptBase) AddRepoKeyFile(ctx context.Context, keyFile string) error { + if aptKey := findBinary("apt-key"); aptKey != "" { + return p.Command(ctx, aptKey, "add", keyFile).Run() + } + data, err := os.ReadFile(keyFile) + if err != nil { + return err + } + return aptWriteKey(data) +} + +// AddRepoKeyURL adds a key for repo package verification from a URL. +func (p *aptBase) AddRepoKeyURL(ctx context.Context, keyURL string) error { + var buf bytes.Buffer + if err := download(ctx, keyURL, &buf); err != nil { + return err + } + if aptKey := findBinary("apt-key"); aptKey != "" { + return p.aptKeyAdd(ctx, aptKey, &buf) + } + return aptWriteKey(buf.Bytes()) +} + +// aptKeyAdd runs `apt-key add -`, feeding the key in through stdin. +func (p *aptBase) aptKeyAdd(ctx context.Context, aptKey string, key io.Reader) error { + cmd := p.Command(ctx, aptKey, "add", "-") + cmd.Stdin = key + return cmd.Run() +} + +// aptKeyDest returns the keyring file path for a key. The fallback path only +// runs on modern apt, which reads both armored (.asc) and binary (.gpg) keys, +// so the suffix is chosen from the key's contents. The name is derived from the +// key so re-adding the same key is idempotent. +func aptKeyDest(key []byte) string { + sum := sha256.Sum256(key) + name := "pkgmgr-" + hex.EncodeToString(sum[:8]) + ext := ".gpg" + if bytes.Contains(key, []byte("-----BEGIN PGP")) { + ext = ".asc" + } + return filepath.Join(APT_KEYRINGS_DIR, name+ext) +} + +// aptWriteKey installs a repo signing key into APT_KEYRINGS_DIR. +func aptWriteKey(key []byte) error { + if err := os.MkdirAll(APT_KEYRINGS_DIR, 0755); err != nil { + return err + } + return os.WriteFile(aptKeyDest(key), key, 0644) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. It simulates an upgrade with apt-get, which both apt and +// apt-get ship, so the listing is consistent and never prompts. +func (p *aptBase) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + aptGet := findBinary("apt-get") + if aptGet == "" { + return nil, fmt.Errorf("unable to find apt-get") + } + + // `apt-get -s upgrade` prints an "Inst [old] ( ...)" line for + // each package that would be upgraded, without changing the system. + args = joinArgs(args, "-s", "upgrade") + return runParseList(p.Command(ctx, aptGet, args...), parseAptUpgradable) +} + +// parseAptUpgradable parses the "Inst [old] ( ...)" lines from a +// simulated apt-get upgrade into a name->candidate-version map. +func parseAptUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 || fields[0] != "Inst" { + continue + } + // The candidate version is the parenthesized token after the name. + for _, f := range fields[2:] { + if strings.HasPrefix(f, "(") { + out[fields[1]] = strings.TrimPrefix(f, "(") + break + } + } + } + return out, scanner.Err() +} diff --git a/aur.go b/aur.go new file mode 100644 index 0000000..da65e70 --- /dev/null +++ b/aur.go @@ -0,0 +1,156 @@ +package pkgmgr + +import ( + "context" + "fmt" +) + +// aurBase provides the behavior shared by the AUR helper managers (yay, paru). +// The helpers are deliberate pacman drop-ins: they accept the same operation +// flags, read repos from PACMAN_CONF, and verify with pacman-key. So repo and +// key management are inherited from Pacman and only the package action verbs run +// the helper binary instead of pacman. +// +// AUR helpers refuse to run as root and escalate to pacman themselves, so their +// actions never use the sudo command wrapper; the embedded dropPrivilege runs +// them unprivileged, dropping to a user via `sudo -u` when the process is root. +// Construct a helper with NewYay or NewParu so its binary name is set. +type aurBase struct { + Pacman + dropPrivilege + helper string // helper binary name, e.g. "yay" or "paru". +} + +// Name is the package manager's name. +func (p *aurBase) Name() string { + return p.helper +} + +// Path is the resolved path to the helper command, or "" if missing. +func (p *aurBase) Path() string { + if p.helper == "" { + return "" + } + return findBinary(p.helper) +} + +// aurExec runs the helper binary with args. It runs unprivileged via the +// embedded dropPrivilege, dropping with `sudo -u` only when the process is +// root, since the helper must run unprivileged and escalate to pacman itself. +func (p *aurBase) aurExec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find %s", p.helper) + } + if err := p.runUnprivileged(ctx, bin, p.dropTarget(), args...); err != nil { + return fmt.Errorf("%s: %w", p.helper, err) + } + return nil +} + +// Sync updates repository metadata. +func (p *aurBase) Sync(ctx context.Context, args []string) error { + return p.aurExec(ctx, joinArgs(args, "-Sy")...) +} + +// Install installs packages from the repositories and the AUR. +func (p *aurBase) Install(ctx context.Context, args []string, packages ...string) error { + return p.aurExec(ctx, append(joinArgs(args, "-S"), packages...)...) +} + +// Remove removes packages. +func (p *aurBase) Remove(ctx context.Context, args []string, packages ...string) error { + return p.aurExec(ctx, append(joinArgs(args, "-R"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *aurBase) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// InstallFile installs a package from a local file. +func (p *aurBase) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.aurExec(ctx, append(joinArgs(args, "-U"), packages...)...) +} + +// UpgradeAll upgrades all packages with available updates, including the AUR. +func (p *aurBase) UpgradeAll(ctx context.Context, args []string) error { + return p.aurExec(ctx, joinArgs(args, "-Syu")...) +} + +// Search searches the repositories and the AUR for packages matching query. The +// helper is a pacman drop-in, so its output is parsed like pacman's. It is run +// unprivileged since the helper refuses to run as root. +func (p *aurBase) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find %s", p.helper) + } + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), joinArgs(args, "-Ss", query)...) + if err != nil { + return nil, err + } + return runSearch(cmd, parsePacmanSearch) +} + +// Info returns detailed information about the named packages. It is run +// unprivileged since the helper refuses to run as root. +func (p *aurBase) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find %s", p.helper) + } + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), append(joinArgs(args, "-Si"), packages...)...) + if err != nil { + return "", err + } + return runCapture(cmd) +} + +// Clean removes cached packages, including the helper's AUR build cache. +func (p *aurBase) Clean(ctx context.Context, args []string) error { + return p.aurExec(ctx, joinArgs(args, "-Sc")...) +} + +// ListUpgradable returns repo and AUR packages with an available update mapped +// to the candidate version. The helper's `-Qu` covers the AUR, and like pacman +// it exits 1 when nothing is upgradable, which is treated as an empty result. +func (p *aurBase) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find %s", p.helper) + } + + // The query needs no privilege, but the helper refuses to run as root, so it + // is invoked unprivileged, dropping with `sudo -u` when the process is root. + args = joinArgs(args, "-Qu") + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), args...) + if err != nil { + return nil, err + } + return runParseList(cmd, parsePacmanUpgradable, 1) +} + +// Yay is the AUR helper manager backed by the yay command. +type Yay struct { + aurBase +} + +// NewYay returns a Yay manager with its helper binary name set. +func NewYay() *Yay { + y := &Yay{} + y.helper = "yay" + return y +} + +// Paru is the AUR helper manager backed by the paru command. +type Paru struct { + aurBase +} + +// NewParu returns a Paru manager with its helper binary name set. +func NewParu() *Paru { + p := &Paru{} + p.helper = "paru" + return p +} diff --git a/brew.go b/brew.go new file mode 100644 index 0000000..38545e5 --- /dev/null +++ b/brew.go @@ -0,0 +1,273 @@ +package pkgmgr + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// brewPaths are the common locations Homebrew installs its command, checked +// when brew is not already on PATH. +var brewPaths = []string{ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + "/home/linuxbrew/.linuxbrew/bin/brew", +} + +// Brew is the Homebrew manager. Homebrew refuses to run as root, so its command +// is never sudo-wrapped; the embedded dropPrivilege runs it unprivileged and +// drops to a user via `sudo -u` when the process is root. +type Brew struct { + baseManager + dropPrivilege +} + +// Name is the package manager's name. +func (p *Brew) Name() string { + return "brew" +} + +// Format is the package format the manager installs. +func (p *Brew) Format() string { + return "bottle" +} + +// Path is the resolved path to the brew command, or "" if missing. +func (p *Brew) Path() string { + if bin, err := exec.LookPath("brew"); err == nil { + return bin + } + for _, candidate := range brewPaths { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} + +// exec runs brew with the given args, failing if brew cannot be found. brew is +// run unprivileged since Homebrew must not run as root. +func (p *Brew) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find brew") + } + return p.runUnprivileged(ctx, bin, p.dropTarget(), args...) +} + +// AddRepo adds a repo by tapping it. config, when set, is the tap's clone URL. +func (p *Brew) AddRepo(name, config string) error { + args := []string{"tap", name} + if config != "" { + args = append(args, config) + } + return p.exec(context.Background(), args...) +} + +// AddRepoURL adds a repo by tapping name from repoURL. +func (p *Brew) AddRepoURL(ctx context.Context, name, repoURL string) error { + return p.exec(ctx, "tap", name, repoURL) +} + +// RemoveRepo removes a repo by untapping it. +func (p *Brew) RemoveRepo(name string) error { + return p.exec(context.Background(), "untap", name) +} + +// GetRepo returns tap information for name, or "" if it is not tapped. +func (p *Brew) GetRepo(name string) string { + bin := p.Path() + if bin == "" { + return "" + } + // Output captures stdout itself, so this bypasses the configured writers. + out, err := exec.CommandContext(context.Background(), bin, "tap-info", name).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// ListRepos returns every tapped repo mapped name->tap-info. The value for each +// name matches what GetRepo returns for it. brew is run unprivileged since +// Homebrew must not run as root. +func (p *Brew) ListRepos(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find brew") + } + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), joinArgs(args, "tap")...) + if err != nil { + return nil, err + } + // Capture stdout directly; the listing is parsed rather than streamed. + cmd.Stdout = nil + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, err + } + + out := make(map[string]string) + scanner := bufio.NewScanner(&buf) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name == "" { + continue + } + out[name] = p.GetRepo(name) + } + return out, scanner.Err() +} + +// AddRepoKey is a no-op: Homebrew verifies downloads itself and has no +// user-managed signing keyring. +func (p *Brew) AddRepoKey(ctx context.Context, key string) error { + return nil +} + +// AddRepoKeyFile is a no-op: Homebrew manages verification itself. +func (p *Brew) AddRepoKeyFile(ctx context.Context, keyFile string) error { + return nil +} + +// AddRepoKeyURL is a no-op: Homebrew manages verification itself. +func (p *Brew) AddRepoKeyURL(ctx context.Context, keyURL string) error { + return nil +} + +// Sync updates repository metadata. +func (p *Brew) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "update")...) +} + +// Install installs packages from the repositories. +func (p *Brew) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *Brew) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "uninstall"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Brew) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...) +} + +// InstallFile installs a package from a local formula or cask file. +func (p *Brew) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Brew) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes stale downloads and old installed versions. brew is run +// unprivileged since Homebrew must not run as root. +func (p *Brew) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "cleanup")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. brew is run unprivileged since Homebrew must not run as +// root. +func (p *Brew) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find brew") + } + + // `brew outdated --verbose` prints "name (oldver) < newver" per package. + args = joinArgs(args, "outdated", "--verbose") + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), args...) + if err != nil { + return nil, err + } + return runParseList(cmd, parseBrewUpgradable) +} + +// parseBrewUpgradable parses brew's "name (oldver) < newver" rows into a +// name->candidate-version map. +func parseBrewUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + // Rows are "name (oldver) < newver"; the candidate is the final field. + if len(fields) < 4 || fields[len(fields)-2] != "<" { + continue + } + out[fields[0]] = fields[len(fields)-1] + } + return out, scanner.Err() +} + +// Search searches the repositories for packages matching query. brew is run +// unprivileged since Homebrew must not run as root. +func (p *Brew) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find brew") + } + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), joinArgs(args, "search", query)...) + if err != nil { + return nil, err + } + return runSearch(cmd, parseBrewSearch) +} + +// parseBrewSearch parses brew's plain list of matching names into search +// results, skipping the "==> Formulae" / "==> Casks" section headers. brew +// search reports neither a version nor a summary. +func parseBrewSearch(r io.Reader) ([]SearchResult, error) { + var out []SearchResult + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "==>") { + continue + } + out = append(out, SearchResult{Name: line}) + } + return out, scanner.Err() +} + +// Info returns detailed information about the named packages. brew is run +// unprivileged since Homebrew must not run as root. +func (p *Brew) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find brew") + } + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), append(joinArgs(args, "info"), packages...)...) + if err != nil { + return "", err + } + return runCapture(cmd) +} + +// ListInstalled returns installed packages mapped to their version. +func (p *Brew) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find brew") + } + + // `brew list --versions` prints "name version [version...]" per line. brew is + // run unprivileged since Homebrew must not run as root. + args = joinArgs(args, "list", "--versions") + cmd, err := p.commandUnprivileged(ctx, bin, p.dropTarget(), args...) + if err != nil { + return nil, err + } + return runVersionList(cmd, " ", false) +} diff --git a/dnf.go b/dnf.go new file mode 100644 index 0000000..6d83989 --- /dev/null +++ b/dnf.go @@ -0,0 +1,101 @@ +package pkgmgr + +import ( + "context" + "fmt" +) + +type Dnf struct { + rpmBase +} + +// Name is the package manager's name. +func (p *Dnf) Name() string { + return "dnf" +} + +// Format is the package format the manager installs. +func (p *Dnf) Format() string { + return "rpm" +} + +// Path is the resolved path to the dnf command, or "" if missing. +func (p *Dnf) Path() string { + return findBinary("dnf") +} + +// exec runs dnf with the given args, failing if dnf cannot be found. +func (p *Dnf) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find dnf") + } + return p.Command(ctx, bin, args...).Run() +} + +// Sync updates repository metadata. +func (p *Dnf) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "makecache")...) +} + +// Install installs packages from the repositories. +func (p *Dnf) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *Dnf) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Dnf) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...) +} + +// InstallFile installs a package from a local file. +func (p *Dnf) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Dnf) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes cached repository metadata and packages. +func (p *Dnf) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "clean", "all")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. +func (p *Dnf) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find dnf") + } + + // `dnf -q list --upgrades` prints "name.arch version repo" per upgrade and + // exits 0 whether or not any are available. + args = joinArgs(args, "-q", "list", "--upgrades") + return runParseList(p.Command(ctx, bin, args...), parseRpmUpgradable) +} + +// Search searches the repositories for packages matching query. +func (p *Dnf) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find dnf") + } + return runSearch(p.Command(ctx, bin, joinArgs(args, "search", query)...), parseRpmSearch) +} + +// Info returns detailed information about the named packages. +func (p *Dnf) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find dnf") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "info"), packages...)...)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bba9b6b --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/grmrgecko/go-package-manager + +go 1.24.3 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..08b7cde --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..31a7704 --- /dev/null +++ b/helpers.go @@ -0,0 +1,334 @@ +package pkgmgr + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// downloadAttempts is the number of times a download is retried before failing. +const downloadAttempts = 3 + +// downloadRetryDelay is how long to wait between download attempts. +var downloadRetryDelay = 10 * time.Second + +// binaryFallbackDirs are the directories searched, in order, when a command is +// not found on PATH. They cover the common locations package managers and their +// helpers install to but that a reduced PATH (cron, system services) may omit. +var binaryFallbackDirs = []string{"/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"} + +// findBinary looks up a command by name in PATH, falling back to a fixed set of +// well-known directories. It returns an empty string when the command cannot be +// found. +func findBinary(name string) string { + // First find the path in the environment. + if p, err := exec.LookPath(name); err == nil { + return p + } + + // If the path isn't in the environment, check the well-known directories. + for _, dir := range binaryFallbackDirs { + fallback := filepath.Join(dir, name) + if _, err := os.Stat(fallback); err == nil { + return fallback + } + } + + // The binary could not be found. + return "" +} + +// download fetches url, retrying on transient failures, and writes the +// response body to w. It honors cancellation via ctx and treats any non-2xx +// HTTP status as a failure so error pages are never mistaken for content. +func download(ctx context.Context, url string, w io.Writer) error { + client := &http.Client{Timeout: 120 * time.Second} + + var lastErr error + for tries := 0; tries < downloadAttempts; tries++ { + // Back off between attempts, but bail out early if cancelled. + if tries != 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(downloadRetryDelay): + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + // Perform the request. + res, err := client.Do(req) + if err != nil { + lastErr = err + log.Debugf("failed to fetch %s, trying again: %v", url, err) + continue + } + + // Reject error pages so they are not written as real content. + if res.StatusCode < 200 || res.StatusCode >= 300 { + res.Body.Close() + lastErr = fmt.Errorf("unexpected status %s fetching %s", res.Status, url) + log.Printf("%v, trying again", lastErr) + continue + } + + // Copy the body to the destination writer. + _, err = io.Copy(w, res.Body) + res.Body.Close() + return err + } + + if lastErr == nil { + lastErr = fmt.Errorf("failed to download %s", url) + } + return lastErr +} + +// downloadToTemp downloads url into a new temporary file and returns its path. +// The caller is responsible for removing the file. +func downloadToTemp(ctx context.Context, url, pattern string) (string, error) { + fd, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + + if err := download(ctx, url, fd); err != nil { + fd.Close() + os.Remove(fd.Name()) + return "", err + } + + if err := fd.Close(); err != nil { + os.Remove(fd.Name()) + return "", err + } + return fd.Name(), nil +} + +// joinArgs returns a new slice of args followed by extra, leaving the caller's +// args slice untouched (plain append can mutate a shared backing array). +func joinArgs(args []string, extra ...string) []string { + out := make([]string, 0, len(args)+len(extra)) + out = append(out, args...) + out = append(out, extra...) + return out +} + +// writeRepoFile writes config to filePath, creating parent directories. +func writeRepoFile(filePath, config string) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return err + } + return os.WriteFile(filePath, []byte(config), 0644) +} + +// downloadRepoFile downloads repoURL into filePath, creating parent +// directories and cleaning up a partial file on failure. +func downloadRepoFile(ctx context.Context, filePath, repoURL string) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return err + } + + fd, err := os.Create(filePath) + if err != nil { + return err + } + + if err := download(ctx, repoURL, fd); err != nil { + fd.Close() + os.Remove(filePath) + return err + } + return fd.Close() +} + +// removeRepoFile removes filePath, treating a missing file as success. +func removeRepoFile(filePath string) error { + err := os.Remove(filePath) + if os.IsNotExist(err) { + return nil + } + return err +} + +// readRepoFile returns the contents of filePath, or "" if it cannot be read. +func readRepoFile(filePath string) string { + data, err := os.ReadFile(filePath) + if err != nil { + return "" + } + return string(data) +} + +// listRepoFiles enumerates the files in dir whose names end in suffix and +// returns them keyed by filename stem, with each value holding the file's +// contents. It backs the ListRepos implementations for managers that store each +// repo as its own file (apt's .list, rpm's .repo). A missing directory yields an +// empty result. The value is read with readRepoFile so a single unreadable file +// does not fail the whole listing. +func listRepoFiles(dir, suffix string) (map[string]string, error) { + out := make(map[string]string) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, suffix) { + continue + } + stem := strings.TrimSuffix(name, suffix) + out[stem] = readRepoFile(filepath.Join(dir, name)) + } + return out, nil +} + +// parseVersionList reads "nameversion" lines from r and returns them as a +// map. When trimEpoch is set, a leading "0:" epoch is stripped from versions. +func parseVersionList(r io.Reader, sep string, trimEpoch bool) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + name, version, ok := strings.Cut(scanner.Text(), sep) + if !ok { + continue + } + name = strings.TrimSpace(name) + version = strings.TrimSpace(version) + if trimEpoch { + version = strings.TrimPrefix(version, "0:") + } + if name == "" { + continue + } + out[name] = version + } + return out, scanner.Err() +} + +// exitCodeAllowed reports whether err is an exec exit error whose status code is +// one of the allowed codes. It lets callers tolerate the non-zero exit some +// managers use to signal "nothing to report" (e.g. pacman -Qu exits 1). +func exitCodeAllowed(err error, allowed []int) bool { + var ee *exec.ExitError + if !errors.As(err, &ee) { + return false + } + return slices.Contains(allowed, ee.ExitCode()) +} + +// runParse runs cmd and parses its stdout with parse, returning the result. It +// wires the command's stdout to a pipe regardless of any configured output +// writer so the listing can be captured. Exit codes in okExit are treated as +// success, which accommodates managers that exit non-zero when there is nothing +// to list. +func runParse[T any](cmd *exec.Cmd, parse func(io.Reader) (T, error), okExit ...int) (T, error) { + var zero T + + // StdoutPipe requires Stdout be unset; the output is captured through the + // pipe here, so clear any writer a command builder may have attached. + cmd.Stdout = nil + stdout, err := cmd.StdoutPipe() + if err != nil { + return zero, err + } + + if err := cmd.Start(); err != nil { + return zero, err + } + + out, perr := parse(stdout) + if werr := cmd.Wait(); werr != nil && !exitCodeAllowed(werr, okExit) { + return zero, werr + } + if perr != nil { + return zero, perr + } + return out, nil +} + +// runParseList runs cmd and parses its stdout into a name->value map. +func runParseList(cmd *exec.Cmd, parse func(io.Reader) (map[string]string, error), okExit ...int) (map[string]string, error) { + return runParse(cmd, parse, okExit...) +} + +// runVersionList runs cmd, parses its stdout as a "nameversion" listing, +// and returns the resulting map. +func runVersionList(cmd *exec.Cmd, sep string, trimEpoch bool) (map[string]string, error) { + return runParseList(cmd, func(r io.Reader) (map[string]string, error) { + return parseVersionList(r, sep, trimEpoch) + }) +} + +// runSearch runs cmd and parses its stdout into a slice of search results. +func runSearch(cmd *exec.Cmd, parse func(io.Reader) ([]SearchResult, error)) ([]SearchResult, error) { + return runParse(cmd, parse) +} + +// runCapture runs cmd and returns its stdout as a string, overriding any +// configured stdout writer so the output can be returned to the caller. +func runCapture(cmd *exec.Cmd) (string, error) { + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return "", err + } + return buf.String(), nil +} + +// parseColumnarSearch parses the "
then indented description" +// search layout shared by apt and pacman. nameVer extracts the package name and +// version from a header line's fields, returning ok=false for non-header lines; +// the first indented line that follows a header becomes that entry's summary. +func parseColumnarSearch(r io.Reader, nameVer func(fields []string) (name, version string, ok bool)) ([]SearchResult, error) { + var out []SearchResult + var cur *SearchResult + flush := func() { + if cur != nil { + out = append(out, *cur) + cur = nil + } + } + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + // Indented lines describe the current entry. + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + if cur != nil && cur.Summary == "" { + cur.Summary = strings.TrimSpace(line) + } + continue + } + name, version, ok := nameVer(strings.Fields(line)) + if !ok { + continue + } + flush() + cur = &SearchResult{Name: name, Version: version} + } + flush() + return out, scanner.Err() +} diff --git a/manager_test.go b/manager_test.go new file mode 100644 index 0000000..099dfdf --- /dev/null +++ b/manager_test.go @@ -0,0 +1,447 @@ +package pkgmgr + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All managers must satisfy the Manager interface. +var ( + _ Manager = (*Apt)(nil) + _ Manager = (*AptGet)(nil) + _ Manager = (*Dnf)(nil) + _ Manager = (*Yum)(nil) + _ Manager = (*Zypper)(nil) + _ Manager = (*Pacman)(nil) + _ Manager = (*Apk)(nil) + _ Manager = (*Brew)(nil) + _ Manager = (*Yay)(nil) + _ Manager = (*Paru)(nil) +) + +func TestCommandWrapper(t *testing.T) { + cases := []struct { + name string + wrapper []string + want []string + }{ + {"none", nil, []string{"apt", "install", "vim"}}, + {"single", []string{"sudo"}, []string{"sudo", "apt", "install", "vim"}}, + {"multi", []string{"sudo", "-n"}, []string{"sudo", "-n", "apt", "install", "vim"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var b baseManager + b.SetCmdWrapper(tc.wrapper) + cmd := b.Command(context.Background(), "apt", "install", "vim") + assert.Equal(t, tc.want, cmd.Args) + }) + } +} + +func TestCommandIO(t *testing.T) { + // With no overrides the os streams are used. + var def baseManager + cmd := def.Command(context.Background(), "echo") + assert.Same(t, os.Stdout, cmd.Stdout) + assert.Same(t, os.Stderr, cmd.Stderr) + assert.Same(t, os.Stdin, cmd.Stdin) + + // SetIO overrides each stream. + var b baseManager + var out, errBuf bytes.Buffer + in := strings.NewReader("input") + b.SetIO(in, &out, &errBuf) + cmd = b.Command(context.Background(), "echo") + assert.Same(t, &out, cmd.Stdout) + assert.Same(t, &errBuf, cmd.Stderr) + assert.Same(t, in, cmd.Stdin) +} + +func TestJoinArgsDoesNotMutate(t *testing.T) { + base := make([]string, 1, 8) // spare capacity would let append clobber base + base[0] = "-y" + + got := joinArgs(base, "install") + got = append(got, "vim") + + assert.Equal(t, []string{"-y"}, base, "joinArgs mutated its input") + assert.Equal(t, []string{"-y", "install", "vim"}, got) +} + +func TestParseVersionList(t *testing.T) { + in := "git := 1:2.39.0\nfoo := 0:1.0\nmalformed line\n\n bar := 2.0 \n" + got, err := parseVersionList(strings.NewReader(in), " := ", true) + require.NoError(t, err) + + want := map[string]string{ + "git": "1:2.39.0", // a non-zero epoch is preserved + "foo": "1.0", // a 0: epoch is trimmed + "bar": "2.0", // surrounding whitespace is trimmed + } + assert.Equal(t, want, got) +} + +func TestParseApkList(t *testing.T) { + in := "busybox-1.36.1-r5\npy3-foo-1.2.3-r0\nlibc6-compat-1.2-r0\ngarbage\n" + got, err := parseApkList(strings.NewReader(in)) + require.NoError(t, err) + + want := map[string]string{ + "busybox": "1.36.1-r5", + "py3-foo": "1.2.3-r0", + "libc6-compat": "1.2-r0", + } + assert.Equal(t, want, got) +} + +func TestAptKeyDest(t *testing.T) { + armored := []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n") + assert.Equal(t, ".asc", filepath.Ext(aptKeyDest(armored)), "armored key should use .asc") + + binary := []byte{0x99, 0x01, 0x02} + assert.Equal(t, ".gpg", filepath.Ext(aptKeyDest(binary)), "binary key should use .gpg") + + // The destination is stable for the same key and lives in the keyrings dir. + assert.Equal(t, aptKeyDest(armored), aptKeyDest(armored), "aptKeyDest should be deterministic") + assert.Equal(t, APT_KEYRINGS_DIR, filepath.Dir(aptKeyDest(armored)), "key should live in the keyrings dir") +} + +func TestIniSectionRoundTrip(t *testing.T) { + file := filepath.Join(t.TempDir(), "pacman.conf") + require.NoError(t, os.WriteFile(file, []byte("[options]\nHoldPkg = pacman\n"), 0644)) + + require.NoError(t, setIniSection(file, "myrepo", "Server = https://example.test/repo")) + assert.Equal(t, "Server = https://example.test/repo", getIniSection(file, "myrepo")) + // Existing sections are preserved. + assert.Equal(t, "HoldPkg = pacman", getIniSection(file, "options"), "options section damaged") + + // Re-adding replaces rather than duplicates. + require.NoError(t, setIniSection(file, "myrepo", "Server = https://example.test/other")) + assert.Equal(t, "Server = https://example.test/other", getIniSection(file, "myrepo")) + + require.NoError(t, removeIniSection(file, "myrepo")) + assert.Empty(t, getIniSection(file, "myrepo"), "removed section still present") + assert.Equal(t, "HoldPkg = pacman", getIniSection(file, "options"), "options section lost after remove") +} + +func TestMarkerSectionRoundTrip(t *testing.T) { + file := filepath.Join(t.TempDir(), "repositories") + require.NoError(t, os.WriteFile(file, []byte("https://dl-cdn.alpinelinux.org/alpine/v3.20/main\n"), 0644)) + + require.NoError(t, setMarkerSection(file, "extra", "https://example.test/alpine")) + assert.Equal(t, "https://example.test/alpine", getMarkerSection(file, "extra")) + + // The pre-existing line is still present. + data, err := os.ReadFile(file) + require.NoError(t, err) + assert.Contains(t, string(data), "dl-cdn.alpinelinux.org", "original repository line was lost") + + require.NoError(t, removeMarkerSection(file, "extra")) + assert.Empty(t, getMarkerSection(file, "extra"), "removed marker section still present") +} + +func TestListRepoFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "docker.list"), []byte("deb https://download.docker.com/linux/ubuntu stable\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "hashicorp.list"), []byte("deb https://apt.releases.hashicorp.com stable main\n"), 0644)) + // A non-matching suffix, a subdirectory whose name ends in the suffix, and a + // missing directory must all be ignored rather than listed or erroring. + require.NoError(t, os.WriteFile(filepath.Join(dir, "ignore.repo"), []byte("noise\n"), 0644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "sub.list"), 0755)) + + got, err := listRepoFiles(dir, ".list") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "docker": "deb https://download.docker.com/linux/ubuntu stable\n", + "hashicorp": "deb https://apt.releases.hashicorp.com stable main\n", + }, got) + + // The listed value matches readRepoFile for each stem, which is the same + // value GetRepo returns for the corresponding manager. + assert.Equal(t, readRepoFile(filepath.Join(dir, "docker.list")), got["docker"], "listRepoFiles value does not match readRepoFile") + + // A missing directory is an empty result, not an error. + got, err = listRepoFiles(filepath.Join(dir, "missing"), ".list") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestListIniSections(t *testing.T) { + file := filepath.Join(t.TempDir(), "pacman.conf") + content := "[options]\nHoldPkg = pacman\n\n" + + "[core]\nSigLevel = Required\nServer = https://example.test/core\n\n" + + "[extra]\nServer = https://example.test/extra\n" + require.NoError(t, os.WriteFile(file, []byte(content), 0644)) + + got, err := listIniSections(file, "options") + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "core": "SigLevel = Required\nServer = https://example.test/core", + "extra": "Server = https://example.test/extra", + }, got) + + // The listing matches getIniSection per name, which is what Pacman.GetRepo + // returns, and the excluded section is omitted. + assert.Equal(t, getIniSection(file, "core"), got["core"], "listIniSections value does not match getIniSection") + assert.NotContains(t, got, "options", "the options section should be excluded from the listing") + + // A missing file is an empty result, not an error. + got, err = listIniSections(filepath.Join(t.TempDir(), "missing")) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestListMarkerSections(t *testing.T) { + file := filepath.Join(t.TempDir(), "repositories") + content := "https://dl-cdn.alpinelinux.org/alpine/v3.20/main\n" + + "# >>> extra\n" + + "https://example.test/alpine\n" + + "# <<< extra\n" + + "# >>> testing\n" + + "https://example.test/testing\n" + + "# <<< testing\n" + require.NoError(t, os.WriteFile(file, []byte(content), 0644)) + + got, err := listMarkerSections(file) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "extra": "https://example.test/alpine", + "testing": "https://example.test/testing", + }, got) + + // The listing matches getMarkerSection per name, which is what Apk.GetRepo + // returns, and unmarked repository lines are not included. + assert.Equal(t, getMarkerSection(file, "extra"), got["extra"], "listMarkerSections value does not match getMarkerSection") + + // A missing file is an empty result, not an error. + got, err = listMarkerSections(filepath.Join(t.TempDir(), "missing")) + require.NoError(t, err) + assert.Empty(t, got) +} + +// TestAptKeyPrefersAptKeyBinary verifies that, for backward compatibility with +// older releases (e.g. Ubuntu 14.04) where apt-key is the only supported method +// of adding keys, AddRepoKey uses apt-key when it is present on PATH rather than +// falling back to the modern keyring directory. +func TestAptKeyPrefersAptKeyBinary(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "stdin") + + // A fake apt-key that records the key it receives on stdin. It uses only + // shell builtins so it does not depend on PATH, which the test overrides. + script := "#!/bin/sh\nread key\necho \"$key\" > " + marker + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "apt-key"), []byte(script), 0755)) + t.Setenv("PATH", dir) + + var a Apt + require.NoError(t, a.AddRepoKey(context.Background(), "MYKEY")) + + data, err := os.ReadFile(marker) + require.NoError(t, err, "apt-key was not invoked") + assert.Equal(t, "MYKEY", strings.TrimSpace(string(data))) +} + +func TestNonRootInvocation(t *testing.T) { + args := []string{"-S", "some-aur-pkg"} + + // As a normal user the command runs directly, unwrapped. + cmd, got, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", false, args) + require.NoError(t, err) + assert.Equal(t, "/usr/bin/yay", cmd) + assert.Equal(t, args, got) + + // As root the command drops to the target user via sudo -u. + cmd, got, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", true, args) + require.NoError(t, err) + assert.Equal(t, "/usr/bin/sudo", cmd) + assert.Equal(t, []string{"-u", "alice", "-H", "/usr/bin/yay", "-S", "some-aur-pkg"}, got) + + // As root with no user to drop to, it fails rather than running as root. + _, _, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "", true, args) + assert.Error(t, err, "expected an error when running as root with no drop user") + + // As root the drop user must not itself be root. + _, _, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "root", true, args) + assert.Error(t, err, "expected an error when the drop user is root") + + // As root with no sudo available, it cannot drop privileges. + _, _, err = nonRootInvocation("/usr/bin/yay", "", "alice", true, args) + assert.Error(t, err, "expected an error when sudo is unavailable") +} + +func TestNonRootInvocationDoesNotMutateArgs(t *testing.T) { + args := []string{"-S", "pkg"} + _, _, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", true, args) + require.NoError(t, err) + assert.Equal(t, []string{"-S", "pkg"}, args, "nonRootInvocation mutated its input") +} + +func TestParseVersionListMissingSeparator(t *testing.T) { + // Lines without the separator are skipped rather than erroring. + got, err := parseVersionList(strings.NewReader("nosephere\na := b\n"), " := ", false) + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "b"}, got) +} + +func TestParseAptUpgradable(t *testing.T) { + // A simulated `apt-get -s upgrade`: an Inst line with a prior version, one + // without (a newly pulled dependency), a Conf line, and noise. + in := "Reading package lists...\n" + + "Inst vim [2:8.1-1] (2:8.2-1 Ubuntu:22.04 [amd64])\n" + + "Inst libfoo (1.1 Ubuntu:22.04 [amd64])\n" + + "Conf vim (2:8.2-1 Ubuntu:22.04 [amd64])\n" + got, err := parseAptUpgradable(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"vim": "2:8.2-1", "libfoo": "1.1"}, got) +} + +func TestParseRpmUpgradable(t *testing.T) { + // `dnf -q list --upgrades` output with a header and a dotted package name. + in := "Available Upgrades\n" + + "vim-enhanced.x86_64 2:9.0.1-1.fc39 updates\n" + + "python3.11.x86_64 3.11.7-1.fc39 updates\n" + + "garbage line here that is not three columns wide\n" + got, err := parseRpmUpgradable(strings.NewReader(in)) + require.NoError(t, err) + // The ".arch" qualifier is stripped, including for names that contain dots. + assert.Equal(t, map[string]string{"vim-enhanced": "2:9.0.1-1.fc39", "python3.11": "3.11.7-1.fc39"}, got) +} + +func TestParseZypperUpgradable(t *testing.T) { + in := "S | Repository | Name | Current Version | Available Version | Arch\n" + + "--+------------+------+-----------------+-------------------+------\n" + + "v | repo-oss | vim | 9.0.1-1.1 | 9.0.2-1.1 | x86_64\n" + got, err := parseZypperUpgradable(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"vim": "9.0.2-1.1"}, got) +} + +func TestParsePacmanUpgradable(t *testing.T) { + // Includes an "[ignored]" suffix and a malformed line. + in := "vim 9.0.1-1 -> 9.0.2-1\nlinux 6.6.1-1 -> 6.6.2-1 [ignored]\nnot an upgrade line\n" + got, err := parsePacmanUpgradable(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"vim": "9.0.2-1", "linux": "6.6.2-1"}, got) +} + +func TestParseApkUpgradable(t *testing.T) { + in := "Installed: Available:\nbusybox-1.36.1-r5 < 1.36.1-r6\nmusl-1.2.4-r2 < 1.2.5-r0\n" + got, err := parseApkUpgradable(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"busybox": "1.36.1-r6", "musl": "1.2.5-r0"}, got) +} + +func TestParseBrewUpgradable(t *testing.T) { + in := "git (2.39.0) < 2.43.0\nwget (1.21.3) < 1.21.4\nnot outdated output\n" + got, err := parseBrewUpgradable(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"git": "2.43.0", "wget": "1.21.4"}, got) +} + +func TestParseAptSearch(t *testing.T) { + in := "Sorting...\nFull Text Search...\n" + + "vim/jammy,now 2:8.2.3995-1ubuntu2 amd64 [installed]\n" + + " Vi IMproved - enhanced vi editor\n\n" + + "xxd/jammy 2:8.2.3995-1ubuntu2 amd64\n" + + " tool to make (or reverse) a hex dump\n" + got, err := parseAptSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim", Version: "2:8.2.3995-1ubuntu2", Summary: "Vi IMproved - enhanced vi editor"}, + {Name: "xxd", Version: "2:8.2.3995-1ubuntu2", Summary: "tool to make (or reverse) a hex dump"}, + }, got) +} + +func TestParseAptCacheSearch(t *testing.T) { + in := "vim - Vi IMproved - enhanced vi editor\nxxd - make a hexdump\nnodashline\n" + got, err := parseAptCacheSearch(strings.NewReader(in)) + require.NoError(t, err) + // Only the first " - " splits name from summary, so summaries may contain " - ". + assert.Equal(t, []SearchResult{ + {Name: "vim", Summary: "Vi IMproved - enhanced vi editor"}, + {Name: "xxd", Summary: "make a hexdump"}, + }, got) +} + +func TestParseRpmSearch(t *testing.T) { + in := "====== Name Exactly Matched: vim ======\n" + + "vim-enhanced.x86_64 : A version of the VIM editor\n" + + "====== Name & Summary Matched: vim ======\n" + + "python3.11-foo.noarch : A library\n" + got, err := parseRpmSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim-enhanced", Summary: "A version of the VIM editor"}, + {Name: "python3.11-foo", Summary: "A library"}, + }, got) +} + +func TestParseZypperSearch(t *testing.T) { + in := "S | Name | Summary | Type\n" + + "--+------+---------+--------\n" + + " | vim | Vi IMproved | package\n" + + "i | vim-data | VIM runtime files | package\n" + got, err := parseZypperSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim", Summary: "Vi IMproved"}, + {Name: "vim-data", Summary: "VIM runtime files"}, + }, got) +} + +func TestParsePacmanSearch(t *testing.T) { + in := "extra/vim 9.0.2-1 [installed]\n" + + " Vi Improved, a programmer's text editor\n" + + "extra/neovim 0.9.4-1\n" + + " Fork of Vim aiming to improve user experience\n" + got, err := parsePacmanSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim", Version: "9.0.2-1", Summary: "Vi Improved, a programmer's text editor"}, + {Name: "neovim", Version: "0.9.4-1", Summary: "Fork of Vim aiming to improve user experience"}, + }, got) +} + +func TestParseApkSearch(t *testing.T) { + in := "vim-9.0.2127-r0 - The VIM editor\nbusybox-1.36.1-r5 - Size optimized toolbox\ngarbage\n" + got, err := parseApkSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim", Version: "9.0.2127-r0", Summary: "The VIM editor"}, + {Name: "busybox", Version: "1.36.1-r5", Summary: "Size optimized toolbox"}, + }, got) +} + +func TestParseBrewSearch(t *testing.T) { + in := "==> Formulae\nvim\nneovim\n==> Casks\nmacvim\n" + got, err := parseBrewSearch(strings.NewReader(in)) + require.NoError(t, err) + assert.Equal(t, []SearchResult{ + {Name: "vim"}, + {Name: "neovim"}, + {Name: "macvim"}, + }, got) +} + +func TestExitCodeAllowed(t *testing.T) { + // A real exit-1 error, as pacman -Qu produces when nothing is upgradable. + exitErr := exec.Command("sh", "-c", "exit 1").Run() + require.Error(t, exitErr, "expected a non-nil exit error") + + assert.True(t, exitCodeAllowed(exitErr, []int{1}), "exit code 1 should be allowed when listed") + assert.False(t, exitCodeAllowed(exitErr, []int{2}), "exit code 1 should not be allowed when only 2 is listed") + assert.False(t, exitCodeAllowed(exitErr, nil), "no allowed codes means nothing is tolerated") + // A non-exit error is never an allowed exit code. + assert.False(t, exitCodeAllowed(errors.New("boom"), []int{1}), "a non-exit error should never be allowed") +} diff --git a/managers.go b/managers.go new file mode 100644 index 0000000..48bc876 --- /dev/null +++ b/managers.go @@ -0,0 +1,165 @@ +package pkgmgr + +import ( + "context" + "io" + "os" + "os/exec" +) + +// SearchResult is a single package match returned by Search. Version and +// Summary are best-effort: a field is left empty when the underlying tool does +// not report it in its search output. +type SearchResult struct { + Name string + Version string + Summary string +} + +// Manager is the interface for working with a system package manager. Methods +// that run a subprocess or perform network I/O take a context.Context so the +// caller can apply cancellation and timeouts. +type Manager interface { + // Name is the package manager's name. + Name() string + // Format is the package format the manager installs (deb, rpm, ...). + Format() string + // Path is the resolved path to the manager's command, or "" if missing. + Path() string + // SetCmdWrapper sets a command wrapper, for example []string{"sudo"}. + SetCmdWrapper(wrapper []string) + // UseSudoWhenNeeded wraps privileged commands with sudo when not already + // root. It is a no-op as root, and managers that must not run as root + // (brew, yay, paru) bypass the wrapper for their own command. + UseSudoWhenNeeded() + // SetIO overrides the stdin, stdout, and stderr used by spawned commands. + // Passing nil for a stream restores its os default. + SetIO(stdin io.Reader, stdout, stderr io.Writer) + // AddRepo adds a repo from a configuration string. + AddRepo(name, config string) error + // AddRepoURL adds a repo whose configuration is downloaded from repoURL. + AddRepoURL(ctx context.Context, name, repoURL string) error + // RemoveRepo removes a repo. + RemoveRepo(name string) error + // GetRepo returns a repo's configuration, or "" if it does not exist. + GetRepo(name string) string + // ListRepos returns all configured repos mapped name->configuration. The + // value for each name matches what GetRepo returns for it. + ListRepos(ctx context.Context, args []string) (map[string]string, error) + // AddRepoKey adds a key for repo package verification. + AddRepoKey(ctx context.Context, key string) error + // AddRepoKeyFile adds a key for repo package verification from a file. + AddRepoKeyFile(ctx context.Context, keyFile string) error + // AddRepoKeyURL adds a key for repo package verification from a URL. + AddRepoKeyURL(ctx context.Context, keyURL string) error + // Sync updates repository metadata. + Sync(ctx context.Context, args []string) error + // Install installs packages from the repositories. + Install(ctx context.Context, args []string, packages ...string) error + // Remove removes packages. + Remove(ctx context.Context, args []string, packages ...string) error + // Upgrade upgrades the named packages. + Upgrade(ctx context.Context, args []string, packages ...string) error + // InstallFile installs a package from a local file. + InstallFile(ctx context.Context, args []string, packages ...string) error + // UpgradeAll upgrades all packages with available updates. + UpgradeAll(ctx context.Context, args []string) error + // Clean removes cached package data to reclaim disk space. + Clean(ctx context.Context, args []string) error + // Search searches the repositories for packages matching query and returns + // the matches. + Search(ctx context.Context, args []string, query string) ([]SearchResult, error) + // Info returns detailed information about the named packages as the + // underlying tool's native text output. + Info(ctx context.Context, args []string, packages ...string) (string, error) + // ListInstalled returns installed packages mapped to their version. + ListInstalled(ctx context.Context, args []string) (map[string]string, error) + // ListUpgradable returns packages with an available update mapped to the + // candidate version, without applying the updates. + ListUpgradable(ctx context.Context, args []string) (map[string]string, error) +} + +// GetSystemManager finds the system manager with a priority of zypper, dnf, +// yum, apt, apt-get, pacman, apk, brew. It returns nil when none are found. +func GetSystemManager() Manager { + managers := []Manager{ + &Zypper{}, + &Dnf{}, + &Yum{}, + &Apt{}, + &AptGet{}, + &Pacman{}, + &Apk{}, + &Brew{}, + } + for _, manager := range managers { + if manager.Path() != "" { + return manager + } + } + return nil +} + +// baseManager holds state and helpers shared by every manager implementation. +type baseManager struct { + wrapper []string + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +// SetCmdWrapper sets a command wrapper, for example []string{"sudo"}. +func (p *baseManager) SetCmdWrapper(wrapper []string) { + p.wrapper = wrapper +} + +// SetIO overrides the stdin, stdout, and stderr used by spawned commands. +func (p *baseManager) SetIO(stdin io.Reader, stdout, stderr io.Writer) { + p.stdin = stdin + p.stdout = stdout + p.stderr = stderr +} + +// stdinOrDefault returns the configured stdin, or os.Stdin when unset. +func (p *baseManager) stdinOrDefault() io.Reader { + if p.stdin != nil { + return p.stdin + } + return os.Stdin +} + +// stdoutOrDefault returns the configured stdout, or os.Stdout when unset. +func (p *baseManager) stdoutOrDefault() io.Writer { + if p.stdout != nil { + return p.stdout + } + return os.Stdout +} + +// stderrOrDefault returns the configured stderr, or os.Stderr when unset. +func (p *baseManager) stderrOrDefault() io.Writer { + if p.stderr != nil { + return p.stderr + } + return os.Stderr +} + +// Command builds an *exec.Cmd for the manager, applying the configured command +// wrapper and I/O streams. The command is bound to ctx for cancellation. +func (p *baseManager) Command(ctx context.Context, command string, args ...string) *exec.Cmd { + if len(p.wrapper) == 1 { + args = append([]string{command}, args...) + command = p.wrapper[0] + } else if len(p.wrapper) > 1 { + argsA := append([]string{}, p.wrapper[1:]...) + argsA = append(argsA, command) + args = append(argsA, args...) + command = p.wrapper[0] + } + cmd := exec.CommandContext(ctx, command, args...) + cmd.Env = os.Environ() + cmd.Stdin = p.stdinOrDefault() + cmd.Stdout = p.stdoutOrDefault() + cmd.Stderr = p.stderrOrDefault() + return cmd +} diff --git a/pacman.go b/pacman.go new file mode 100644 index 0000000..5e2964f --- /dev/null +++ b/pacman.go @@ -0,0 +1,362 @@ +package pkgmgr + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" +) + +// PACMAN_CONF is the configuration file pacman reads its repos from. pacman has +// no drop-in repo directory by default, so repos are managed as [name] sections +// within this file. +const PACMAN_CONF = "/etc/pacman.conf" + +type Pacman struct { + baseManager +} + +// Name is the package manager's name. +func (p *Pacman) Name() string { + return "pacman" +} + +// Format is the package format the manager installs. +func (p *Pacman) Format() string { + return "pkg" +} + +// Path is the resolved path to the pacman command, or "" if missing. +func (p *Pacman) Path() string { + return findBinary("pacman") +} + +// exec runs pacman with the given args, failing if pacman cannot be found. +func (p *Pacman) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find pacman") + } + return p.Command(ctx, bin, args...).Run() +} + +// AddRepo adds a repo as a [name] section in PACMAN_CONF. +func (p *Pacman) AddRepo(name, config string) error { + return setIniSection(PACMAN_CONF, name, config) +} + +// AddRepoURL adds a repo whose configuration is downloaded from repoURL. +func (p *Pacman) AddRepoURL(ctx context.Context, name, repoURL string) error { + var buf strings.Builder + if err := download(ctx, repoURL, &buf); err != nil { + return err + } + return setIniSection(PACMAN_CONF, name, buf.String()) +} + +// RemoveRepo removes a repo's section from PACMAN_CONF. +func (p *Pacman) RemoveRepo(name string) error { + return removeIniSection(PACMAN_CONF, name) +} + +// GetRepo returns a repo's configuration, or "" if it does not exist. +func (p *Pacman) GetRepo(name string) string { + return getIniSection(PACMAN_CONF, name) +} + +// ListRepos returns every repository section in PACMAN_CONF mapped name->body. +// The value matches what GetRepo returns for that name. pacman treats every +// section other than [options] as a repository, so [options] is excluded. +func (p *Pacman) ListRepos(ctx context.Context, args []string) (map[string]string, error) { + return listIniSections(PACMAN_CONF, "options") +} + +// pacmanKey runs pacman-key with the given args. +func (p *Pacman) pacmanKey(ctx context.Context, args ...string) error { + bin := findBinary("pacman-key") + if bin == "" { + return fmt.Errorf("unable to find pacman-key") + } + return p.Command(ctx, bin, args...).Run() +} + +// AddRepoKey adds a key for repo package verification. +func (p *Pacman) AddRepoKey(ctx context.Context, key string) error { + fd, err := os.CreateTemp("", "GPG") + if err != nil { + return err + } + name := fd.Name() + _, werr := fd.WriteString(key) + cerr := fd.Close() + if werr != nil { + os.Remove(name) + return werr + } + if cerr != nil { + os.Remove(name) + return cerr + } + + err = p.pacmanKey(ctx, "--add", name) + os.Remove(name) + return err +} + +// AddRepoKeyFile adds a key for repo package verification from a file. +func (p *Pacman) AddRepoKeyFile(ctx context.Context, keyFile string) error { + return p.pacmanKey(ctx, "--add", keyFile) +} + +// AddRepoKeyURL adds a key for repo package verification from a URL. +func (p *Pacman) AddRepoKeyURL(ctx context.Context, keyURL string) error { + tmp, err := downloadToTemp(ctx, keyURL, "GPG") + if err != nil { + return err + } + err = p.pacmanKey(ctx, "--add", tmp) + os.Remove(tmp) + return err +} + +// Sync updates repository metadata. +func (p *Pacman) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "-Sy")...) +} + +// Install installs packages from the repositories. +func (p *Pacman) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "-S"), packages...)...) +} + +// Remove removes packages. +func (p *Pacman) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "-R"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Pacman) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// InstallFile installs a package from a local file. +func (p *Pacman) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "-U"), packages...)...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Pacman) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "-Syu")...) +} + +// Clean removes cached packages from the package cache. +func (p *Pacman) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "-Sc")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. +func (p *Pacman) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find pacman") + } + + // `pacman -Qu` prints "name oldver -> newver" and exits 1 when nothing is + // upgradable, which is treated as an empty result rather than an error. + args = joinArgs(args, "-Qu") + return runParseList(p.Command(ctx, bin, args...), parsePacmanUpgradable, 1) +} + +// parsePacmanUpgradable parses the "name oldver -> newver" rows printed by +// pacman and its AUR-helper drop-ins into a name->candidate-version map. +func parsePacmanUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + // Rows are "name oldver -> newver"; the candidate follows the arrow. + if len(fields) < 4 || fields[2] != "->" { + continue + } + out[fields[0]] = fields[3] + } + return out, scanner.Err() +} + +// Search searches the repositories for packages matching query. +func (p *Pacman) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find pacman") + } + return runSearch(p.Command(ctx, bin, joinArgs(args, "-Ss", query)...), parsePacmanSearch) +} + +// parsePacmanSearch parses the "repo/name version" headers and indented +// descriptions printed by `pacman -Ss` and its AUR-helper drop-ins into search +// results. +func parsePacmanSearch(r io.Reader) ([]SearchResult, error) { + return parseColumnarSearch(r, func(fields []string) (string, string, bool) { + // Header rows look like "repo/name version [tags]". + if len(fields) < 2 || !strings.Contains(fields[0], "/") { + return "", "", false + } + _, name, _ := strings.Cut(fields[0], "/") + return name, fields[1], true + }) +} + +// Info returns detailed information about the named packages. +func (p *Pacman) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find pacman") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "-Si"), packages...)...)) +} + +// ListInstalled returns installed packages mapped to their version. +func (p *Pacman) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find pacman") + } + + // `pacman -Q` prints one "name version" pair per line. + args = joinArgs(args, "-Q") + return runVersionList(p.Command(ctx, bin, args...), " ", false) +} + +// isIniHeader reports whether line is the [name] section header. +func isIniHeader(line, name string) bool { + return strings.TrimSpace(line) == "["+name+"]" +} + +// isAnyIniHeader reports whether line is any [section] header. +func isAnyIniHeader(line string) bool { + t := strings.TrimSpace(line) + return strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") +} + +// getIniSection returns the body of the [name] section in file, or "" when the +// section or file is absent. The header line itself is not included. +func getIniSection(file, name string) string { + data, err := os.ReadFile(file) + if err != nil { + return "" + } + + var body []string + found, in := false, false + for _, line := range strings.Split(string(data), "\n") { + if isAnyIniHeader(line) { + if in { + break + } + in = isIniHeader(line, name) + found = found || in + continue + } + if in { + body = append(body, line) + } + } + if !found { + return "" + } + return strings.TrimSpace(strings.Join(body, "\n")) +} + +// listIniSections returns every [section] in file mapped name->body, excluding +// any section whose name is in exclude. Each body matches getIniSection. A +// missing file yields an empty result. +func listIniSections(file string, exclude ...string) (map[string]string, error) { + out := make(map[string]string) + data, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + + skip := make(map[string]bool, len(exclude)) + for _, name := range exclude { + skip[name] = true + } + + var name string + var body []string + flush := func() { + if name != "" && !skip[name] { + out[name] = strings.TrimSpace(strings.Join(body, "\n")) + } + name, body = "", nil + } + for _, line := range strings.Split(string(data), "\n") { + if isAnyIniHeader(line) { + flush() + t := strings.TrimSpace(line) + name = strings.TrimSuffix(strings.TrimPrefix(t, "["), "]") + continue + } + if name != "" { + body = append(body, line) + } + } + flush() + return out, nil +} + +// removeIniSection removes the [name] section (header and body) from file, +// treating a missing file as success. +func removeIniSection(file, name string) error { + data, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var out []string + skip := false + for _, line := range strings.Split(string(data), "\n") { + if isAnyIniHeader(line) { + skip = isIniHeader(line, name) + } + if skip { + continue + } + out = append(out, line) + } + return os.WriteFile(file, []byte(strings.Join(out, "\n")), 0644) +} + +// setIniSection inserts or replaces the [name] section in file with body. +func setIniSection(file, name, body string) error { + if err := removeIniSection(file, name); err != nil { + return err + } + + data, err := os.ReadFile(file) + if err != nil && !os.IsNotExist(err) { + return err + } + + var b strings.Builder + existing := strings.TrimRight(string(data), "\n") + if existing != "" { + b.WriteString(existing) + b.WriteString("\n\n") + } + b.WriteString("[" + name + "]\n") + if body = strings.TrimRight(body, "\n"); body != "" { + b.WriteString(body) + b.WriteString("\n") + } + return os.WriteFile(file, []byte(b.String()), 0644) +} diff --git a/privilege.go b/privilege.go new file mode 100644 index 0000000..eaf0b1c --- /dev/null +++ b/privilege.go @@ -0,0 +1,92 @@ +package pkgmgr + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +// dropPrivilege carries the configuration for managers whose command must not +// run as root (Homebrew, AUR helpers). Such managers embed it to gain +// SetDropUser and the shared unprivileged-invocation logic. +type dropPrivilege struct { + dropUser string // user to drop to when running as root; "" falls back to SUDO_USER. +} + +// SetDropUser sets the unprivileged user the command drops to when the process +// runs as root. When unset, the SUDO_USER environment variable is used. +func (d *dropPrivilege) SetDropUser(user string) { + d.dropUser = user +} + +// dropTarget returns the unprivileged user to drop to, preferring an explicitly +// configured user over SUDO_USER. It returns "" when neither is set. +func (d *dropPrivilege) dropTarget() string { + if d.dropUser != "" { + return d.dropUser + } + return os.Getenv("SUDO_USER") +} + +// nonRootInvocation resolves the command and args for running bin, which must +// not execute as root. When not running as root bin is run directly; when +// running as root it is run as dropUser via sudo so it never executes +// privileged. asRoot, the resolved binaries, and dropUser are passed in so the +// decision can be tested. +func nonRootInvocation(bin, sudoBin, dropUser string, asRoot bool, args []string) (string, []string, error) { + if !asRoot { + return bin, args, nil + } + if dropUser == "" { + return "", nil, fmt.Errorf("running as root and no unprivileged user is set; set SUDO_USER or call SetDropUser") + } + if dropUser == "root" { + return "", nil, fmt.Errorf("the configured drop user is root, which is not permitted for this manager") + } + if sudoBin == "" { + return "", nil, fmt.Errorf("unable to find sudo to drop privileges") + } + // -H gives the command the target user's HOME so user-level tooling works. + out := append([]string{"-u", dropUser, "-H", bin}, args...) + return sudoBin, out, nil +} + +// commandUnprivileged builds an *exec.Cmd for bin that runs unprivileged, +// dropping to dropUser via sudo when the process is root. It deliberately +// bypasses the sudo command wrapper, since these managers escalate (or refuse) +// on their own. The configured I/O streams are applied. +func (p *baseManager) commandUnprivileged(ctx context.Context, bin, dropUser string, args ...string) (*exec.Cmd, error) { + command, cargs, err := nonRootInvocation(bin, findBinary("sudo"), dropUser, os.Geteuid() == 0, args) + if err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, command, cargs...) + cmd.Env = os.Environ() + cmd.Stdin = p.stdinOrDefault() + cmd.Stdout = p.stdoutOrDefault() + cmd.Stderr = p.stderrOrDefault() + return cmd, nil +} + +// runUnprivileged runs bin with args unprivileged, dropping to dropUser via +// sudo when the process is root. +func (p *baseManager) runUnprivileged(ctx context.Context, bin, dropUser string, args ...string) error { + cmd, err := p.commandUnprivileged(ctx, bin, dropUser, args...) + if err != nil { + return err + } + return cmd.Run() +} + +// UseSudoWhenNeeded sets the command wrapper to sudo when the process is not +// already root, so privileged operations escalate. It is a no-op when running +// as root. Managers that must not run as root (brew, yay, paru) bypass the +// wrapper for their own command, so this only affects their privileged +// pacman-side helpers and the root-required managers. +func (p *baseManager) UseSudoWhenNeeded() { + if os.Geteuid() != 0 { + p.SetCmdWrapper([]string{"sudo"}) + } +} diff --git a/rpm_common.go b/rpm_common.go new file mode 100644 index 0000000..4765783 --- /dev/null +++ b/rpm_common.go @@ -0,0 +1,158 @@ +package pkgmgr + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// YUM_REPO_DIR is where rpm-based managers (dnf, yum, zypper) write repo files. +const YUM_REPO_DIR = "/etc/yum.repos.d" + +// rpmBase provides the repo, signing-key, and package-listing behavior shared +// by the rpm-based managers. Each concrete manager embeds it and supplies its +// own Name, Format, Path, and action subcommands. +type rpmBase struct { + baseManager +} + +func (p *rpmBase) repoPath(name string) string { + return filepath.Join(YUM_REPO_DIR, name+".repo") +} + +// AddRepo adds a repo from a configuration string. +func (p *rpmBase) AddRepo(name, config string) error { + return writeRepoFile(p.repoPath(name), config) +} + +// AddRepoURL adds a repo whose configuration is downloaded from repoURL. +func (p *rpmBase) AddRepoURL(ctx context.Context, name, repoURL string) error { + return downloadRepoFile(ctx, p.repoPath(name), repoURL) +} + +// RemoveRepo removes a repo. +func (p *rpmBase) RemoveRepo(name string) error { + return removeRepoFile(p.repoPath(name)) +} + +// GetRepo returns a repo's configuration, or "" if it does not exist. +func (p *rpmBase) GetRepo(name string) string { + return readRepoFile(p.repoPath(name)) +} + +// ListRepos returns every repo file in YUM_REPO_DIR keyed by file stem. The +// value matches what GetRepo returns for that name. +func (p *rpmBase) ListRepos(ctx context.Context, args []string) (map[string]string, error) { + return listRepoFiles(YUM_REPO_DIR, ".repo") +} + +// importKey runs `rpm --import` on a key file. +func (p *rpmBase) importKey(ctx context.Context, keyFile string) error { + rpm := findBinary("rpm") + if rpm == "" { + return fmt.Errorf("unable to find rpm") + } + return p.Command(ctx, rpm, "--import", keyFile).Run() +} + +// AddRepoKey adds a key for repo package verification. +func (p *rpmBase) AddRepoKey(ctx context.Context, key string) error { + fd, err := os.CreateTemp("", "GPG") + if err != nil { + return err + } + name := fd.Name() + _, werr := fd.WriteString(key) + cerr := fd.Close() + if werr != nil { + os.Remove(name) + return werr + } + if cerr != nil { + os.Remove(name) + return cerr + } + + err = p.importKey(ctx, name) + os.Remove(name) + return err +} + +// AddRepoKeyFile adds a key for repo package verification from a file. +func (p *rpmBase) AddRepoKeyFile(ctx context.Context, keyFile string) error { + return p.importKey(ctx, keyFile) +} + +// AddRepoKeyURL adds a key for repo package verification from a URL. +func (p *rpmBase) AddRepoKeyURL(ctx context.Context, keyURL string) error { + tmp, err := downloadToTemp(ctx, keyURL, "GPG") + if err != nil { + return err + } + err = p.importKey(ctx, tmp) + os.Remove(tmp) + return err +} + +// ListInstalled returns installed packages mapped to their version. It queries +// rpm directly so the listing is consistent across dnf, yum, and zypper. +func (p *rpmBase) ListInstalled(ctx context.Context, args []string) (map[string]string, error) { + rpm := findBinary("rpm") + if rpm == "" { + return nil, fmt.Errorf("unable to find rpm") + } + + args = joinArgs(args, "-qa", "--queryformat", "%{NAME} := %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n") + return runVersionList(p.Command(ctx, rpm, args...), " := ", true) +} + +// parseRpmSearch parses the "name.arch : summary" lines printed by `dnf search` +// and `yum search` into search results, skipping the "==== ... ====" section +// headers. Neither tool reports a version in its search output. +func parseRpmSearch(r io.Reader) ([]SearchResult, error) { + var out []SearchResult + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "==") { + continue + } + name, summary, ok := strings.Cut(line, " : ") + if !ok { + continue + } + name = strings.TrimSpace(name) + // The arch is the final dot-separated component of the name. + if i := strings.LastIndex(name, "."); i != -1 { + name = name[:i] + } + out = append(out, SearchResult{Name: name, Summary: strings.TrimSpace(summary)}) + } + return out, scanner.Err() +} + +// parseRpmUpgradable parses the "name.arch version repo" rows printed by +// `dnf list --upgrades` and `yum list updates` into a name->version map. The +// trailing ".arch" qualifier is stripped from the package name. +func parseRpmUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + // Data rows have exactly three columns; headers and notices do not. + if len(fields) != 3 { + continue + } + name := fields[0] + // The arch is always the final dot-separated component of the name. + if i := strings.LastIndex(name, "."); i != -1 { + name = name[:i] + } + out[name] = fields[1] + } + return out, scanner.Err() +} diff --git a/yum.go b/yum.go new file mode 100644 index 0000000..02c89b0 --- /dev/null +++ b/yum.go @@ -0,0 +1,101 @@ +package pkgmgr + +import ( + "context" + "fmt" +) + +type Yum struct { + rpmBase +} + +// Name is the package manager's name. +func (p *Yum) Name() string { + return "yum" +} + +// Format is the package format the manager installs. +func (p *Yum) Format() string { + return "rpm" +} + +// Path is the resolved path to the yum command, or "" if missing. +func (p *Yum) Path() string { + return findBinary("yum") +} + +// exec runs yum with the given args, failing if yum cannot be found. +func (p *Yum) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find yum") + } + return p.Command(ctx, bin, args...).Run() +} + +// Sync updates repository metadata. +func (p *Yum) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "makecache")...) +} + +// Install installs packages from the repositories. +func (p *Yum) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *Yum) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Yum) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "upgrade"), packages...)...) +} + +// InstallFile installs a package from a local file. +func (p *Yum) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Yum) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "upgrade")...) +} + +// Clean removes cached repository metadata and packages. +func (p *Yum) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "clean", "all")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. +func (p *Yum) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find yum") + } + + // `yum -q list updates` prints "name.arch version repo" per upgrade and + // exits 0 whether or not any are available. + args = joinArgs(args, "-q", "list", "updates") + return runParseList(p.Command(ctx, bin, args...), parseRpmUpgradable) +} + +// Search searches the repositories for packages matching query. +func (p *Yum) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find yum") + } + return runSearch(p.Command(ctx, bin, joinArgs(args, "search", query)...), parseRpmSearch) +} + +// Info returns detailed information about the named packages. +func (p *Yum) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find yum") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "info"), packages...)...)) +} diff --git a/zypper.go b/zypper.go new file mode 100644 index 0000000..9eab163 --- /dev/null +++ b/zypper.go @@ -0,0 +1,160 @@ +package pkgmgr + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +type Zypper struct { + rpmBase +} + +// Name is the package manager's name. +func (p *Zypper) Name() string { + return "zypper" +} + +// Format is the package format the manager installs. +func (p *Zypper) Format() string { + return "rpm" +} + +// Path is the resolved path to the zypper command, or "" if missing. +func (p *Zypper) Path() string { + return findBinary("zypper") +} + +// exec runs zypper with the given args, failing if zypper cannot be found. +func (p *Zypper) exec(ctx context.Context, args ...string) error { + bin := p.Path() + if bin == "" { + return fmt.Errorf("unable to find zypper") + } + return p.Command(ctx, bin, args...).Run() +} + +// Sync updates repository metadata. +func (p *Zypper) Sync(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "refresh")...) +} + +// Install installs packages from the repositories. +func (p *Zypper) Install(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "install"), packages...)...) +} + +// Remove removes packages. +func (p *Zypper) Remove(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "remove"), packages...)...) +} + +// Upgrade upgrades the named packages. +func (p *Zypper) Upgrade(ctx context.Context, args []string, packages ...string) error { + return p.exec(ctx, append(joinArgs(args, "update"), packages...)...) +} + +// InstallFile installs a package from a local file. +func (p *Zypper) InstallFile(ctx context.Context, args []string, packages ...string) error { + return p.Install(ctx, args, packages...) +} + +// UpgradeAll upgrades all packages with available updates. +func (p *Zypper) UpgradeAll(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "update")...) +} + +// Clean removes cached repository metadata and packages. +func (p *Zypper) Clean(ctx context.Context, args []string) error { + return p.exec(ctx, joinArgs(args, "clean")...) +} + +// ListUpgradable returns packages with an available update mapped to the +// candidate version. +func (p *Zypper) ListUpgradable(ctx context.Context, args []string) (map[string]string, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find zypper") + } + + // `zypper -q list-updates` prints a pipe-delimited table of available updates. + args = joinArgs(args, "-q", "list-updates") + return runParseList(p.Command(ctx, bin, args...), parseZypperUpgradable) +} + +// parseZypperUpgradable parses zypper's pipe-delimited update table into a +// name->available-version map. Rows are "S | Repo | Name | Current | Available +// | Arch"; the header and separator rows are skipped. +func parseZypperUpgradable(r io.Reader) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "|") { + continue + } + cols := strings.Split(line, "|") + if len(cols) < 5 { + continue + } + for i := range cols { + cols[i] = strings.TrimSpace(cols[i]) + } + // Skip the header row, identified by its literal "S" status column. + if cols[0] == "S" || cols[2] == "Name" { + continue + } + if cols[2] == "" { + continue + } + out[cols[2]] = cols[4] + } + return out, scanner.Err() +} + +// Search searches the repositories for packages matching query. +func (p *Zypper) Search(ctx context.Context, args []string, query string) ([]SearchResult, error) { + bin := p.Path() + if bin == "" { + return nil, fmt.Errorf("unable to find zypper") + } + return runSearch(p.Command(ctx, bin, joinArgs(args, "search", query)...), parseZypperSearch) +} + +// parseZypperSearch parses zypper's pipe-delimited search table into search +// results. Rows are "S | Name | Summary | Type"; the default search output does +// not report a version. +func parseZypperSearch(r io.Reader) ([]SearchResult, error) { + var out []SearchResult + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "|") { + continue + } + cols := strings.Split(line, "|") + if len(cols) < 3 { + continue + } + for i := range cols { + cols[i] = strings.TrimSpace(cols[i]) + } + // Skip the header row and any blank-name rows. + if cols[1] == "Name" || cols[1] == "" { + continue + } + out = append(out, SearchResult{Name: cols[1], Summary: cols[2]}) + } + return out, scanner.Err() +} + +// Info returns detailed information about the named packages. +func (p *Zypper) Info(ctx context.Context, args []string, packages ...string) (string, error) { + bin := p.Path() + if bin == "" { + return "", fmt.Errorf("unable to find zypper") + } + return runCapture(p.Command(ctx, bin, append(joinArgs(args, "info"), packages...)...)) +}