first commit
This commit is contained in:
commit
03f319e014
19 changed files with 3341 additions and 0 deletions
19
LICENSE
Normal file
19
LICENSE
Normal 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
141
README.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# go-package-manager
|
||||||
|
|
||||||
|
[](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
405
apk.go
Normal 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
118
apt-get.go
Normal 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
113
apt.go
Normal 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
163
apt_common.go
Normal 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
156
aur.go
Normal 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
273
brew.go
Normal 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
101
dnf.go
Normal 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...)...))
|
||||||
|
}
|
||||||
15
go.mod
Normal file
15
go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
module github.com/grmrgecko/go-package-manager
|
||||||
|
|
||||||
|
go 1.24.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
18
go.sum
Normal file
18
go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
334
helpers.go
Normal file
334
helpers.go
Normal 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()
|
||||||
|
}
|
||||||
447
manager_test.go
Normal file
447
manager_test.go
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
package pkgmgr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All managers must satisfy the Manager interface.
|
||||||
|
var (
|
||||||
|
_ Manager = (*Apt)(nil)
|
||||||
|
_ Manager = (*AptGet)(nil)
|
||||||
|
_ Manager = (*Dnf)(nil)
|
||||||
|
_ Manager = (*Yum)(nil)
|
||||||
|
_ Manager = (*Zypper)(nil)
|
||||||
|
_ Manager = (*Pacman)(nil)
|
||||||
|
_ Manager = (*Apk)(nil)
|
||||||
|
_ Manager = (*Brew)(nil)
|
||||||
|
_ Manager = (*Yay)(nil)
|
||||||
|
_ Manager = (*Paru)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommandWrapper(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
wrapper []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"none", nil, []string{"apt", "install", "vim"}},
|
||||||
|
{"single", []string{"sudo"}, []string{"sudo", "apt", "install", "vim"}},
|
||||||
|
{"multi", []string{"sudo", "-n"}, []string{"sudo", "-n", "apt", "install", "vim"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var b baseManager
|
||||||
|
b.SetCmdWrapper(tc.wrapper)
|
||||||
|
cmd := b.Command(context.Background(), "apt", "install", "vim")
|
||||||
|
assert.Equal(t, tc.want, cmd.Args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandIO(t *testing.T) {
|
||||||
|
// With no overrides the os streams are used.
|
||||||
|
var def baseManager
|
||||||
|
cmd := def.Command(context.Background(), "echo")
|
||||||
|
assert.Same(t, os.Stdout, cmd.Stdout)
|
||||||
|
assert.Same(t, os.Stderr, cmd.Stderr)
|
||||||
|
assert.Same(t, os.Stdin, cmd.Stdin)
|
||||||
|
|
||||||
|
// SetIO overrides each stream.
|
||||||
|
var b baseManager
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
in := strings.NewReader("input")
|
||||||
|
b.SetIO(in, &out, &errBuf)
|
||||||
|
cmd = b.Command(context.Background(), "echo")
|
||||||
|
assert.Same(t, &out, cmd.Stdout)
|
||||||
|
assert.Same(t, &errBuf, cmd.Stderr)
|
||||||
|
assert.Same(t, in, cmd.Stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinArgsDoesNotMutate(t *testing.T) {
|
||||||
|
base := make([]string, 1, 8) // spare capacity would let append clobber base
|
||||||
|
base[0] = "-y"
|
||||||
|
|
||||||
|
got := joinArgs(base, "install")
|
||||||
|
got = append(got, "vim")
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"-y"}, base, "joinArgs mutated its input")
|
||||||
|
assert.Equal(t, []string{"-y", "install", "vim"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVersionList(t *testing.T) {
|
||||||
|
in := "git := 1:2.39.0\nfoo := 0:1.0\nmalformed line\n\n bar := 2.0 \n"
|
||||||
|
got, err := parseVersionList(strings.NewReader(in), " := ", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"git": "1:2.39.0", // a non-zero epoch is preserved
|
||||||
|
"foo": "1.0", // a 0: epoch is trimmed
|
||||||
|
"bar": "2.0", // surrounding whitespace is trimmed
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseApkList(t *testing.T) {
|
||||||
|
in := "busybox-1.36.1-r5\npy3-foo-1.2.3-r0\nlibc6-compat-1.2-r0\ngarbage\n"
|
||||||
|
got, err := parseApkList(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"busybox": "1.36.1-r5",
|
||||||
|
"py3-foo": "1.2.3-r0",
|
||||||
|
"libc6-compat": "1.2-r0",
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAptKeyDest(t *testing.T) {
|
||||||
|
armored := []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n")
|
||||||
|
assert.Equal(t, ".asc", filepath.Ext(aptKeyDest(armored)), "armored key should use .asc")
|
||||||
|
|
||||||
|
binary := []byte{0x99, 0x01, 0x02}
|
||||||
|
assert.Equal(t, ".gpg", filepath.Ext(aptKeyDest(binary)), "binary key should use .gpg")
|
||||||
|
|
||||||
|
// The destination is stable for the same key and lives in the keyrings dir.
|
||||||
|
assert.Equal(t, aptKeyDest(armored), aptKeyDest(armored), "aptKeyDest should be deterministic")
|
||||||
|
assert.Equal(t, APT_KEYRINGS_DIR, filepath.Dir(aptKeyDest(armored)), "key should live in the keyrings dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIniSectionRoundTrip(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "pacman.conf")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("[options]\nHoldPkg = pacman\n"), 0644))
|
||||||
|
|
||||||
|
require.NoError(t, setIniSection(file, "myrepo", "Server = https://example.test/repo"))
|
||||||
|
assert.Equal(t, "Server = https://example.test/repo", getIniSection(file, "myrepo"))
|
||||||
|
// Existing sections are preserved.
|
||||||
|
assert.Equal(t, "HoldPkg = pacman", getIniSection(file, "options"), "options section damaged")
|
||||||
|
|
||||||
|
// Re-adding replaces rather than duplicates.
|
||||||
|
require.NoError(t, setIniSection(file, "myrepo", "Server = https://example.test/other"))
|
||||||
|
assert.Equal(t, "Server = https://example.test/other", getIniSection(file, "myrepo"))
|
||||||
|
|
||||||
|
require.NoError(t, removeIniSection(file, "myrepo"))
|
||||||
|
assert.Empty(t, getIniSection(file, "myrepo"), "removed section still present")
|
||||||
|
assert.Equal(t, "HoldPkg = pacman", getIniSection(file, "options"), "options section lost after remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkerSectionRoundTrip(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "repositories")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("https://dl-cdn.alpinelinux.org/alpine/v3.20/main\n"), 0644))
|
||||||
|
|
||||||
|
require.NoError(t, setMarkerSection(file, "extra", "https://example.test/alpine"))
|
||||||
|
assert.Equal(t, "https://example.test/alpine", getMarkerSection(file, "extra"))
|
||||||
|
|
||||||
|
// The pre-existing line is still present.
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), "dl-cdn.alpinelinux.org", "original repository line was lost")
|
||||||
|
|
||||||
|
require.NoError(t, removeMarkerSection(file, "extra"))
|
||||||
|
assert.Empty(t, getMarkerSection(file, "extra"), "removed marker section still present")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRepoFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "docker.list"), []byte("deb https://download.docker.com/linux/ubuntu stable\n"), 0644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "hashicorp.list"), []byte("deb https://apt.releases.hashicorp.com stable main\n"), 0644))
|
||||||
|
// A non-matching suffix, a subdirectory whose name ends in the suffix, and a
|
||||||
|
// missing directory must all be ignored rather than listed or erroring.
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignore.repo"), []byte("noise\n"), 0644))
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "sub.list"), 0755))
|
||||||
|
|
||||||
|
got, err := listRepoFiles(dir, ".list")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"docker": "deb https://download.docker.com/linux/ubuntu stable\n",
|
||||||
|
"hashicorp": "deb https://apt.releases.hashicorp.com stable main\n",
|
||||||
|
}, got)
|
||||||
|
|
||||||
|
// The listed value matches readRepoFile for each stem, which is the same
|
||||||
|
// value GetRepo returns for the corresponding manager.
|
||||||
|
assert.Equal(t, readRepoFile(filepath.Join(dir, "docker.list")), got["docker"], "listRepoFiles value does not match readRepoFile")
|
||||||
|
|
||||||
|
// A missing directory is an empty result, not an error.
|
||||||
|
got, err = listRepoFiles(filepath.Join(dir, "missing"), ".list")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIniSections(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "pacman.conf")
|
||||||
|
content := "[options]\nHoldPkg = pacman\n\n" +
|
||||||
|
"[core]\nSigLevel = Required\nServer = https://example.test/core\n\n" +
|
||||||
|
"[extra]\nServer = https://example.test/extra\n"
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(content), 0644))
|
||||||
|
|
||||||
|
got, err := listIniSections(file, "options")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"core": "SigLevel = Required\nServer = https://example.test/core",
|
||||||
|
"extra": "Server = https://example.test/extra",
|
||||||
|
}, got)
|
||||||
|
|
||||||
|
// The listing matches getIniSection per name, which is what Pacman.GetRepo
|
||||||
|
// returns, and the excluded section is omitted.
|
||||||
|
assert.Equal(t, getIniSection(file, "core"), got["core"], "listIniSections value does not match getIniSection")
|
||||||
|
assert.NotContains(t, got, "options", "the options section should be excluded from the listing")
|
||||||
|
|
||||||
|
// A missing file is an empty result, not an error.
|
||||||
|
got, err = listIniSections(filepath.Join(t.TempDir(), "missing"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMarkerSections(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "repositories")
|
||||||
|
content := "https://dl-cdn.alpinelinux.org/alpine/v3.20/main\n" +
|
||||||
|
"# >>> extra\n" +
|
||||||
|
"https://example.test/alpine\n" +
|
||||||
|
"# <<< extra\n" +
|
||||||
|
"# >>> testing\n" +
|
||||||
|
"https://example.test/testing\n" +
|
||||||
|
"# <<< testing\n"
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(content), 0644))
|
||||||
|
|
||||||
|
got, err := listMarkerSections(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"extra": "https://example.test/alpine",
|
||||||
|
"testing": "https://example.test/testing",
|
||||||
|
}, got)
|
||||||
|
|
||||||
|
// The listing matches getMarkerSection per name, which is what Apk.GetRepo
|
||||||
|
// returns, and unmarked repository lines are not included.
|
||||||
|
assert.Equal(t, getMarkerSection(file, "extra"), got["extra"], "listMarkerSections value does not match getMarkerSection")
|
||||||
|
|
||||||
|
// A missing file is an empty result, not an error.
|
||||||
|
got, err = listMarkerSections(filepath.Join(t.TempDir(), "missing"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAptKeyPrefersAptKeyBinary verifies that, for backward compatibility with
|
||||||
|
// older releases (e.g. Ubuntu 14.04) where apt-key is the only supported method
|
||||||
|
// of adding keys, AddRepoKey uses apt-key when it is present on PATH rather than
|
||||||
|
// falling back to the modern keyring directory.
|
||||||
|
func TestAptKeyPrefersAptKeyBinary(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
marker := filepath.Join(dir, "stdin")
|
||||||
|
|
||||||
|
// A fake apt-key that records the key it receives on stdin. It uses only
|
||||||
|
// shell builtins so it does not depend on PATH, which the test overrides.
|
||||||
|
script := "#!/bin/sh\nread key\necho \"$key\" > " + marker + "\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "apt-key"), []byte(script), 0755))
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
var a Apt
|
||||||
|
require.NoError(t, a.AddRepoKey(context.Background(), "MYKEY"))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(marker)
|
||||||
|
require.NoError(t, err, "apt-key was not invoked")
|
||||||
|
assert.Equal(t, "MYKEY", strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonRootInvocation(t *testing.T) {
|
||||||
|
args := []string{"-S", "some-aur-pkg"}
|
||||||
|
|
||||||
|
// As a normal user the command runs directly, unwrapped.
|
||||||
|
cmd, got, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", false, args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/usr/bin/yay", cmd)
|
||||||
|
assert.Equal(t, args, got)
|
||||||
|
|
||||||
|
// As root the command drops to the target user via sudo -u.
|
||||||
|
cmd, got, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", true, args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/usr/bin/sudo", cmd)
|
||||||
|
assert.Equal(t, []string{"-u", "alice", "-H", "/usr/bin/yay", "-S", "some-aur-pkg"}, got)
|
||||||
|
|
||||||
|
// As root with no user to drop to, it fails rather than running as root.
|
||||||
|
_, _, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "", true, args)
|
||||||
|
assert.Error(t, err, "expected an error when running as root with no drop user")
|
||||||
|
|
||||||
|
// As root the drop user must not itself be root.
|
||||||
|
_, _, err = nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "root", true, args)
|
||||||
|
assert.Error(t, err, "expected an error when the drop user is root")
|
||||||
|
|
||||||
|
// As root with no sudo available, it cannot drop privileges.
|
||||||
|
_, _, err = nonRootInvocation("/usr/bin/yay", "", "alice", true, args)
|
||||||
|
assert.Error(t, err, "expected an error when sudo is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonRootInvocationDoesNotMutateArgs(t *testing.T) {
|
||||||
|
args := []string{"-S", "pkg"}
|
||||||
|
_, _, err := nonRootInvocation("/usr/bin/yay", "/usr/bin/sudo", "alice", true, args)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"-S", "pkg"}, args, "nonRootInvocation mutated its input")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVersionListMissingSeparator(t *testing.T) {
|
||||||
|
// Lines without the separator are skipped rather than erroring.
|
||||||
|
got, err := parseVersionList(strings.NewReader("nosephere\na := b\n"), " := ", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"a": "b"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptUpgradable(t *testing.T) {
|
||||||
|
// A simulated `apt-get -s upgrade`: an Inst line with a prior version, one
|
||||||
|
// without (a newly pulled dependency), a Conf line, and noise.
|
||||||
|
in := "Reading package lists...\n" +
|
||||||
|
"Inst vim [2:8.1-1] (2:8.2-1 Ubuntu:22.04 [amd64])\n" +
|
||||||
|
"Inst libfoo (1.1 Ubuntu:22.04 [amd64])\n" +
|
||||||
|
"Conf vim (2:8.2-1 Ubuntu:22.04 [amd64])\n"
|
||||||
|
got, err := parseAptUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"vim": "2:8.2-1", "libfoo": "1.1"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRpmUpgradable(t *testing.T) {
|
||||||
|
// `dnf -q list --upgrades` output with a header and a dotted package name.
|
||||||
|
in := "Available Upgrades\n" +
|
||||||
|
"vim-enhanced.x86_64 2:9.0.1-1.fc39 updates\n" +
|
||||||
|
"python3.11.x86_64 3.11.7-1.fc39 updates\n" +
|
||||||
|
"garbage line here that is not three columns wide\n"
|
||||||
|
got, err := parseRpmUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// The ".arch" qualifier is stripped, including for names that contain dots.
|
||||||
|
assert.Equal(t, map[string]string{"vim-enhanced": "2:9.0.1-1.fc39", "python3.11": "3.11.7-1.fc39"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZypperUpgradable(t *testing.T) {
|
||||||
|
in := "S | Repository | Name | Current Version | Available Version | Arch\n" +
|
||||||
|
"--+------------+------+-----------------+-------------------+------\n" +
|
||||||
|
"v | repo-oss | vim | 9.0.1-1.1 | 9.0.2-1.1 | x86_64\n"
|
||||||
|
got, err := parseZypperUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"vim": "9.0.2-1.1"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePacmanUpgradable(t *testing.T) {
|
||||||
|
// Includes an "[ignored]" suffix and a malformed line.
|
||||||
|
in := "vim 9.0.1-1 -> 9.0.2-1\nlinux 6.6.1-1 -> 6.6.2-1 [ignored]\nnot an upgrade line\n"
|
||||||
|
got, err := parsePacmanUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"vim": "9.0.2-1", "linux": "6.6.2-1"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseApkUpgradable(t *testing.T) {
|
||||||
|
in := "Installed: Available:\nbusybox-1.36.1-r5 < 1.36.1-r6\nmusl-1.2.4-r2 < 1.2.5-r0\n"
|
||||||
|
got, err := parseApkUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"busybox": "1.36.1-r6", "musl": "1.2.5-r0"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrewUpgradable(t *testing.T) {
|
||||||
|
in := "git (2.39.0) < 2.43.0\nwget (1.21.3) < 1.21.4\nnot outdated output\n"
|
||||||
|
got, err := parseBrewUpgradable(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"git": "2.43.0", "wget": "1.21.4"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptSearch(t *testing.T) {
|
||||||
|
in := "Sorting...\nFull Text Search...\n" +
|
||||||
|
"vim/jammy,now 2:8.2.3995-1ubuntu2 amd64 [installed]\n" +
|
||||||
|
" Vi IMproved - enhanced vi editor\n\n" +
|
||||||
|
"xxd/jammy 2:8.2.3995-1ubuntu2 amd64\n" +
|
||||||
|
" tool to make (or reverse) a hex dump\n"
|
||||||
|
got, err := parseAptSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim", Version: "2:8.2.3995-1ubuntu2", Summary: "Vi IMproved - enhanced vi editor"},
|
||||||
|
{Name: "xxd", Version: "2:8.2.3995-1ubuntu2", Summary: "tool to make (or reverse) a hex dump"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptCacheSearch(t *testing.T) {
|
||||||
|
in := "vim - Vi IMproved - enhanced vi editor\nxxd - make a hexdump\nnodashline\n"
|
||||||
|
got, err := parseAptCacheSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Only the first " - " splits name from summary, so summaries may contain " - ".
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim", Summary: "Vi IMproved - enhanced vi editor"},
|
||||||
|
{Name: "xxd", Summary: "make a hexdump"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRpmSearch(t *testing.T) {
|
||||||
|
in := "====== Name Exactly Matched: vim ======\n" +
|
||||||
|
"vim-enhanced.x86_64 : A version of the VIM editor\n" +
|
||||||
|
"====== Name & Summary Matched: vim ======\n" +
|
||||||
|
"python3.11-foo.noarch : A library\n"
|
||||||
|
got, err := parseRpmSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim-enhanced", Summary: "A version of the VIM editor"},
|
||||||
|
{Name: "python3.11-foo", Summary: "A library"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZypperSearch(t *testing.T) {
|
||||||
|
in := "S | Name | Summary | Type\n" +
|
||||||
|
"--+------+---------+--------\n" +
|
||||||
|
" | vim | Vi IMproved | package\n" +
|
||||||
|
"i | vim-data | VIM runtime files | package\n"
|
||||||
|
got, err := parseZypperSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim", Summary: "Vi IMproved"},
|
||||||
|
{Name: "vim-data", Summary: "VIM runtime files"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePacmanSearch(t *testing.T) {
|
||||||
|
in := "extra/vim 9.0.2-1 [installed]\n" +
|
||||||
|
" Vi Improved, a programmer's text editor\n" +
|
||||||
|
"extra/neovim 0.9.4-1\n" +
|
||||||
|
" Fork of Vim aiming to improve user experience\n"
|
||||||
|
got, err := parsePacmanSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim", Version: "9.0.2-1", Summary: "Vi Improved, a programmer's text editor"},
|
||||||
|
{Name: "neovim", Version: "0.9.4-1", Summary: "Fork of Vim aiming to improve user experience"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseApkSearch(t *testing.T) {
|
||||||
|
in := "vim-9.0.2127-r0 - The VIM editor\nbusybox-1.36.1-r5 - Size optimized toolbox\ngarbage\n"
|
||||||
|
got, err := parseApkSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim", Version: "9.0.2127-r0", Summary: "The VIM editor"},
|
||||||
|
{Name: "busybox", Version: "1.36.1-r5", Summary: "Size optimized toolbox"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrewSearch(t *testing.T) {
|
||||||
|
in := "==> Formulae\nvim\nneovim\n==> Casks\nmacvim\n"
|
||||||
|
got, err := parseBrewSearch(strings.NewReader(in))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []SearchResult{
|
||||||
|
{Name: "vim"},
|
||||||
|
{Name: "neovim"},
|
||||||
|
{Name: "macvim"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeAllowed(t *testing.T) {
|
||||||
|
// A real exit-1 error, as pacman -Qu produces when nothing is upgradable.
|
||||||
|
exitErr := exec.Command("sh", "-c", "exit 1").Run()
|
||||||
|
require.Error(t, exitErr, "expected a non-nil exit error")
|
||||||
|
|
||||||
|
assert.True(t, exitCodeAllowed(exitErr, []int{1}), "exit code 1 should be allowed when listed")
|
||||||
|
assert.False(t, exitCodeAllowed(exitErr, []int{2}), "exit code 1 should not be allowed when only 2 is listed")
|
||||||
|
assert.False(t, exitCodeAllowed(exitErr, nil), "no allowed codes means nothing is tolerated")
|
||||||
|
// A non-exit error is never an allowed exit code.
|
||||||
|
assert.False(t, exitCodeAllowed(errors.New("boom"), []int{1}), "a non-exit error should never be allowed")
|
||||||
|
}
|
||||||
165
managers.go
Normal file
165
managers.go
Normal 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
362
pacman.go
Normal 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
92
privilege.go
Normal 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
158
rpm_common.go
Normal 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
101
yum.go
Normal 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
160
zypper.go
Normal 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...)...))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue