405 lines
11 KiB
Go
405 lines
11 KiB
Go
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)
|
|
}
|