go-package-manager/helpers.go
2026-07-01 17:47:49 -05:00

334 lines
9.5 KiB
Go

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