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