362 lines
10 KiB
Go
362 lines
10 KiB
Go
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)
|
|
}
|