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

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