first commit

This commit is contained in:
James Coleman 2026-06-30 09:53:16 -05:00
commit deb6ad0278
19 changed files with 3511 additions and 0 deletions

19
LICENSE Normal file
View file

@ -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.

141
README.md Normal file
View file

@ -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.

405
apk.go Normal file
View file

@ -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)
}

118
apt-get.go Normal file
View file

@ -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)
}

113
apt.go Normal file
View file

@ -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)
}

163
apt_common.go Normal file
View file

@ -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 <name> [old] (<new> ...)" 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 <name> [old] (<new> ...)" 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()
}

156
aur.go Normal file
View file

@ -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
}

273
brew.go Normal file
View file

@ -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)
}

101
dnf.go Normal file
View file

@ -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...)...))
}

7
go.mod Normal file
View file

@ -0,0 +1,7 @@
module github.com/grmrgecko/go-package-manager
go 1.24.3
require github.com/sirupsen/logrus v1.9.3
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

15
go.sum Normal file
View file

@ -0,0 +1,15 @@
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 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

334
helpers.go Normal file
View file

@ -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 "name<sep>version" 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 "name<sep>version" 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 "<header line> 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()
}

628
manager_test.go Normal file
View file

@ -0,0 +1,628 @@
package pkgmgr
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
// 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")
if !slices.Equal(cmd.Args, tc.want) {
t.Errorf("Args = %v, want %v", cmd.Args, tc.want)
}
})
}
}
func TestCommandIO(t *testing.T) {
// With no overrides the os streams are used.
var def baseManager
cmd := def.Command(context.Background(), "echo")
if cmd.Stdout != os.Stdout || cmd.Stderr != os.Stderr || cmd.Stdin != os.Stdin {
t.Error("default Command should use the os streams")
}
// 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")
if cmd.Stdout != &out || cmd.Stderr != &errBuf || cmd.Stdin != in {
t.Error("SetIO streams were not applied to the command")
}
}
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")
if len(base) != 1 || base[0] != "-y" {
t.Errorf("joinArgs mutated its input: %v", base)
}
if want := []string{"-y", "install", "vim"}; !slices.Equal(got, want) {
t.Errorf("joinArgs = %v, want %v", got, want)
}
}
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)
if err != nil {
t.Fatal(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
}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%q = %q, want %q", k, got[k], v)
}
}
}
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))
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"busybox": "1.36.1-r5",
"py3-foo": "1.2.3-r0",
"libc6-compat": "1.2-r0",
}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%q = %q, want %q", k, got[k], v)
}
}
}
func TestAptKeyDest(t *testing.T) {
armored := []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n")
if ext := filepath.Ext(aptKeyDest(armored)); ext != ".asc" {
t.Errorf("armored key ext = %q, want .asc", ext)
}
binary := []byte{0x99, 0x01, 0x02}
if ext := filepath.Ext(aptKeyDest(binary)); ext != ".gpg" {
t.Errorf("binary key ext = %q, want .gpg", ext)
}
// The destination is stable for the same key and lives in the keyrings dir.
if a, b := aptKeyDest(armored), aptKeyDest(armored); a != b {
t.Errorf("aptKeyDest not deterministic: %q vs %q", a, b)
}
if dir := filepath.Dir(aptKeyDest(armored)); dir != APT_KEYRINGS_DIR {
t.Errorf("key dir = %q, want %q", dir, APT_KEYRINGS_DIR)
}
}
func TestIniSectionRoundTrip(t *testing.T) {
file := filepath.Join(t.TempDir(), "pacman.conf")
if err := os.WriteFile(file, []byte("[options]\nHoldPkg = pacman\n"), 0644); err != nil {
t.Fatal(err)
}
if err := setIniSection(file, "myrepo", "Server = https://example.test/repo"); err != nil {
t.Fatal(err)
}
if got := getIniSection(file, "myrepo"); got != "Server = https://example.test/repo" {
t.Errorf("getIniSection = %q", got)
}
// Existing sections are preserved.
if got := getIniSection(file, "options"); got != "HoldPkg = pacman" {
t.Errorf("options section damaged: %q", got)
}
// Re-adding replaces rather than duplicates.
if err := setIniSection(file, "myrepo", "Server = https://example.test/other"); err != nil {
t.Fatal(err)
}
if got := getIniSection(file, "myrepo"); got != "Server = https://example.test/other" {
t.Errorf("after replace = %q", got)
}
if err := removeIniSection(file, "myrepo"); err != nil {
t.Fatal(err)
}
if got := getIniSection(file, "myrepo"); got != "" {
t.Errorf("removed section still present: %q", got)
}
if got := getIniSection(file, "options"); got != "HoldPkg = pacman" {
t.Errorf("options section lost after remove: %q", got)
}
}
func TestMarkerSectionRoundTrip(t *testing.T) {
file := filepath.Join(t.TempDir(), "repositories")
if err := os.WriteFile(file, []byte("https://dl-cdn.alpinelinux.org/alpine/v3.20/main\n"), 0644); err != nil {
t.Fatal(err)
}
if err := setMarkerSection(file, "extra", "https://example.test/alpine"); err != nil {
t.Fatal(err)
}
if got := getMarkerSection(file, "extra"); got != "https://example.test/alpine" {
t.Errorf("getMarkerSection = %q", got)
}
// The pre-existing line is still present.
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "dl-cdn.alpinelinux.org") {
t.Error("original repository line was lost")
}
if err := removeMarkerSection(file, "extra"); err != nil {
t.Fatal(err)
}
if got := getMarkerSection(file, "extra"); got != "" {
t.Errorf("removed marker section still present: %q", got)
}
}
func TestListRepoFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "docker.list"), []byte("deb https://download.docker.com/linux/ubuntu stable\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "hashicorp.list"), []byte("deb https://apt.releases.hashicorp.com stable main\n"), 0644); err != nil {
t.Fatal(err)
}
// 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.
if err := os.WriteFile(filepath.Join(dir, "ignore.repo"), []byte("noise\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "sub.list"), 0755); err != nil {
t.Fatal(err)
}
got, err := listRepoFiles(dir, ".list")
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{
"docker": "deb https://download.docker.com/linux/ubuntu stable\n",
"hashicorp": "deb https://apt.releases.hashicorp.com stable main\n",
})
// The listed value matches readRepoFile for each stem, which is the same
// value GetRepo returns for the corresponding manager.
if got["docker"] != readRepoFile(filepath.Join(dir, "docker.list")) {
t.Error("listRepoFiles value does not match readRepoFile")
}
// A missing directory is an empty result, not an error.
got, err = listRepoFiles(filepath.Join(dir, "missing"), ".list")
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{})
}
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"
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatal(err)
}
got, err := listIniSections(file, "options")
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{
"core": "SigLevel = Required\nServer = https://example.test/core",
"extra": "Server = https://example.test/extra",
})
// The listing matches getIniSection per name, which is what Pacman.GetRepo
// returns, and the excluded section is omitted.
if got["core"] != getIniSection(file, "core") {
t.Error("listIniSections value does not match getIniSection")
}
if _, ok := got["options"]; ok {
t.Error("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"))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{})
}
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"
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatal(err)
}
got, err := listMarkerSections(file)
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{
"extra": "https://example.test/alpine",
"testing": "https://example.test/testing",
})
// The listing matches getMarkerSection per name, which is what Apk.GetRepo
// returns, and unmarked repository lines are not included.
if got["extra"] != getMarkerSection(file, "extra") {
t.Error("listMarkerSections value does not match getMarkerSection")
}
// A missing file is an empty result, not an error.
got, err = listMarkerSections(filepath.Join(t.TempDir(), "missing"))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{})
}
// 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"
if err := os.WriteFile(filepath.Join(dir, "apt-key"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
var a Apt
if err := a.AddRepoKey(context.Background(), "MYKEY"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("apt-key was not invoked: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "MYKEY" {
t.Errorf("apt-key received %q, want MYKEY", got)
}
}
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)
if err != nil {
t.Fatal(err)
}
if cmd != "/usr/bin/yay" {
t.Errorf("command = %q, want /usr/bin/yay", cmd)
}
if !slices.Equal(got, args) {
t.Errorf("args = %v, want %v", got, args)
}
// 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)
if err != nil {
t.Fatal(err)
}
if cmd != "/usr/bin/sudo" {
t.Errorf("command = %q, want /usr/bin/sudo", cmd)
}
want := []string{"-u", "alice", "-H", "/usr/bin/yay", "-S", "some-aur-pkg"}
if !slices.Equal(got, want) {
t.Errorf("args = %v, want %v", got, want)
}
// As root with no user to drop to, it fails rather than running as root.
if _, _, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "", true, args); err == nil {
t.Error("expected an error when running as root with no drop user")
}
// As root the drop user must not itself be root.
if _, _, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "root", true, args); err == nil {
t.Error("expected an error when the drop user is root")
}
// As root with no sudo available, it cannot drop privileges.
if _, _, err := nonRootInvocation("/usr/bin/yay", "", "alice", true, args); err == nil {
t.Error("expected an error when sudo is unavailable")
}
}
func TestNonRootInvocationDoesNotMutateArgs(t *testing.T) {
args := []string{"-S", "pkg"}
if _, _, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", true, args); err != nil {
t.Fatal(err)
}
if want := []string{"-S", "pkg"}; !slices.Equal(args, want) {
t.Errorf("nonRootInvocation mutated its input: %v", args)
}
}
func TestParseVersionListMissingSeparator(t *testing.T) {
// Lines without the separator are skipped rather than erroring.
got, err := parseVersionList(strings.NewReader("nosephere\na := b\n"), " := ", false)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got["a"] != "b" {
t.Errorf("got %v, want map[a:b]", got)
}
}
// wantMap fails the test unless got equals want exactly.
func wantMap(t *testing.T, got, want map[string]string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%q = %q, want %q", k, got[k], v)
}
}
}
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))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{"vim": "2:8.2-1", "libfoo": "1.1"})
}
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))
if err != nil {
t.Fatal(err)
}
// The ".arch" qualifier is stripped, including for names that contain dots.
wantMap(t, got, map[string]string{"vim-enhanced": "2:9.0.1-1.fc39", "python3.11": "3.11.7-1.fc39"})
}
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))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{"vim": "9.0.2-1.1"})
}
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))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{"vim": "9.0.2-1", "linux": "6.6.2-1"})
}
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))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{"busybox": "1.36.1-r6", "musl": "1.2.5-r0"})
}
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))
if err != nil {
t.Fatal(err)
}
wantMap(t, got, map[string]string{"git": "2.43.0", "wget": "1.21.4"})
}
// wantResults fails the test unless got equals want exactly, in order.
func wantResults(t *testing.T, got, want []SearchResult) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
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))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []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"},
})
}
func TestParseAptCacheSearch(t *testing.T) {
in := "vim - Vi IMproved - enhanced vi editor\nxxd - make a hexdump\nnodashline\n"
got, err := parseAptCacheSearch(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
// Only the first " - " splits name from summary, so summaries may contain " - ".
wantResults(t, got, []SearchResult{
{Name: "vim", Summary: "Vi IMproved - enhanced vi editor"},
{Name: "xxd", Summary: "make a hexdump"},
})
}
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))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []SearchResult{
{Name: "vim-enhanced", Summary: "A version of the VIM editor"},
{Name: "python3.11-foo", Summary: "A library"},
})
}
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))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []SearchResult{
{Name: "vim", Summary: "Vi IMproved"},
{Name: "vim-data", Summary: "VIM runtime files"},
})
}
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))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []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"},
})
}
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))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []SearchResult{
{Name: "vim", Version: "9.0.2127-r0", Summary: "The VIM editor"},
{Name: "busybox", Version: "1.36.1-r5", Summary: "Size optimized toolbox"},
})
}
func TestParseBrewSearch(t *testing.T) {
in := "==> Formulae\nvim\nneovim\n==> Casks\nmacvim\n"
got, err := parseBrewSearch(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
wantResults(t, got, []SearchResult{
{Name: "vim"},
{Name: "neovim"},
{Name: "macvim"},
})
}
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()
if exitErr == nil {
t.Fatal("expected a non-nil exit error")
}
if !exitCodeAllowed(exitErr, []int{1}) {
t.Error("exit code 1 should be allowed when listed")
}
if exitCodeAllowed(exitErr, []int{2}) {
t.Error("exit code 1 should not be allowed when only 2 is listed")
}
if exitCodeAllowed(exitErr, nil) {
t.Error("no allowed codes means nothing is tolerated")
}
// A non-exit error is never an allowed exit code.
if exitCodeAllowed(errors.New("boom"), []int{1}) {
t.Error("a non-exit error should never be allowed")
}
}

165
managers.go Normal file
View file

@ -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
}

362
pacman.go Normal file
View file

@ -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)
}

92
privilege.go Normal file
View file

@ -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"})
}
}

158
rpm_common.go Normal file
View file

@ -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()
}

101
yum.go Normal file
View file

@ -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...)...))
}

160
zypper.go Normal file
View file

@ -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...)...))
}