Mirrors were generated once and never again and caches never cleaned. The module holds node-package-manager (hq ADR 0207), declares pacman itself, owns /etc/pacman.conf whole — [options] cannot take an appended block — with the union of the enabled repositories and improved options, proven by pacman-conf in its test, and enables reflector.timer (its config owned) and paccache.timer. Fifteen tools from a Go bundle; transactions run as transient units so a call's timeout never kills pacman mid-transaction (to-be 42).
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package main
|
|
|
|
// Reading the package manager (novox/hq to-be 42 Phase 1, research 026/05): what is installed and
|
|
// why, what a search finds, what owns a path, what a package holds, what is orphaned or foreign.
|
|
// Every one of these reads the local or sync databases, which any account may; none escalates.
|
|
|
|
import (
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Found is one package a search found.
|
|
type Found struct {
|
|
Repository string `json:"repository"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
Installed bool `json:"installed"`
|
|
InstalledAs string `json:"installed_version,omitempty"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
var searchHeader = regexp.MustCompile(`^(\S+)/(\S+) (\S+)(?: \(([^)]*)\))?(?: \[installed(?:: ([^\]]+))?\])?$`)
|
|
|
|
// ParseSearch reads `pacman -Ss`: a header line per package and its description indented beneath.
|
|
func ParseSearch(out string) []Found {
|
|
found := []Found{}
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if strings.TrimSpace(l) == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(l, " ") {
|
|
if n := len(found); n > 0 {
|
|
found[n-1].Description = strings.TrimSpace(strings.TrimSpace(found[n-1].Description + " " + strings.TrimSpace(l)))
|
|
}
|
|
continue
|
|
}
|
|
m := searchHeader.FindStringSubmatch(l)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
f := Found{Repository: m[1], Name: m[2], Version: m[3], Installed: strings.Contains(l, "[installed")}
|
|
if m[4] != "" {
|
|
f.Groups = strings.Fields(m[4])
|
|
}
|
|
if f.Installed {
|
|
f.InstalledAs = f.Version
|
|
if m[5] != "" {
|
|
f.InstalledAs = m[5]
|
|
}
|
|
}
|
|
found = append(found, f)
|
|
}
|
|
return found
|
|
}
|
|
|
|
// none is pacman's way of saying a query found nothing: status 1 and nothing said.
|
|
func none(r Ran) bool {
|
|
return r.Status == 1 && r.Err == "" && strings.TrimSpace(r.Stdout+r.Stderr) == ""
|
|
}
|
|
|
|
// query runs a pacman query whose empty answer is status 1, and fails only on a real failure.
|
|
func (m *Machine) query(args ...string) (string, error) {
|
|
r := m.Run(bg(), "pacman", args...)
|
|
if r.Status == 0 && r.Err == "" {
|
|
return r.Stdout, nil
|
|
}
|
|
if none(r) {
|
|
return "", nil
|
|
}
|
|
return "", failure("pacman", "pacman", r)
|
|
}
|
|
|
|
// Search is `pacman -Ss` over the sync databases, bounded.
|
|
func (m *Machine) Search(words string, limit int) (map[string]any, error) {
|
|
out, err := m.query(append([]string{"-Ss", "--"}, strings.Fields(words)...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
found := ParseSearch(out)
|
|
return bound("packages", found, limit), nil
|
|
}
|
|
|
|
// bound is a list answered with its count, cut to a limit and saying so.
|
|
func bound[T any](key string, list []T, limit int) map[string]any {
|
|
out := map[string]any{"count": len(list), "truncated": false}
|
|
if limit > 0 && len(list) > limit {
|
|
list = list[:limit]
|
|
out["truncated"] = true
|
|
}
|
|
out[key] = list
|
|
return out
|
|
}
|
|
|
|
// ParseInfo reads `pacman -Qi`/`-Si`: `Key : value` lines, continuation lines indented beneath.
|
|
// A field that is a list (two spaces between members) is answered as one, and "None" as empty.
|
|
func ParseInfo(out string) []map[string]any {
|
|
var pkgs []map[string]any
|
|
var cur map[string]any
|
|
last := ""
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if strings.TrimSpace(l) == "" {
|
|
if cur != nil {
|
|
pkgs = append(pkgs, cur)
|
|
cur = nil
|
|
}
|
|
continue
|
|
}
|
|
if cur == nil {
|
|
cur = map[string]any{}
|
|
}
|
|
if k, v, ok := strings.Cut(l, " : "); ok && !strings.HasPrefix(l, " ") {
|
|
last = strings.TrimSpace(k)
|
|
cur[last] = infoValue(last, strings.TrimSpace(v))
|
|
continue
|
|
}
|
|
// A continuation: the optional dependencies, one per line.
|
|
if last != "" {
|
|
v := strings.TrimSpace(l)
|
|
switch prev := cur[last].(type) {
|
|
case []string:
|
|
cur[last] = append(prev, v)
|
|
case string:
|
|
cur[last] = []string{prev, v}
|
|
}
|
|
}
|
|
}
|
|
if cur != nil {
|
|
pkgs = append(pkgs, cur)
|
|
}
|
|
return pkgs
|
|
}
|
|
|
|
var listFields = map[string]bool{
|
|
"Licenses": true, "Groups": true, "Provides": true, "Depends On": true, "Optional Deps": true,
|
|
"Required By": true, "Optional For": true, "Conflicts With": true, "Replaces": true,
|
|
}
|
|
|
|
func infoValue(key, v string) any {
|
|
if !listFields[key] {
|
|
return v
|
|
}
|
|
if v == "None" {
|
|
return []string{}
|
|
}
|
|
if key == "Optional Deps" {
|
|
return []string{v}
|
|
}
|
|
return strings.Fields(v)
|
|
}
|
|
|
|
// Info is one package as the local database knows it, or the sync databases when it is not installed.
|
|
func (m *Machine) Info(name string) (map[string]any, error) {
|
|
r := m.Run(bg(), "pacman", "-Qi", "--", name)
|
|
installed := true
|
|
if r.Status != 0 {
|
|
if r.Err != "" || !strings.Contains(r.Stderr, "was not found") {
|
|
return nil, failure("pacman", "pacman", r)
|
|
}
|
|
installed = false
|
|
if r = m.Run(bg(), "pacman", "-Si", "--", name); r.Status != 0 || r.Err != "" {
|
|
if strings.Contains(r.Stderr, "was not found") {
|
|
return nil, fmt.Errorf("no package %s, installed or in a repository", name)
|
|
}
|
|
return nil, failure("pacman", "pacman", r)
|
|
}
|
|
}
|
|
pkgs := ParseInfo(r.Stdout)
|
|
if len(pkgs) == 0 {
|
|
return nil, fmt.Errorf("pacman said nothing about %s", name)
|
|
}
|
|
return map[string]any{"installed": installed, "package": pkgs[0]}, nil
|
|
}
|
|
|
|
// Package is an installed package and why it is installed.
|
|
type Package struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Reason string `json:"reason"`
|
|
Foreign bool `json:"foreign"`
|
|
}
|
|
|
|
func nameVersions(out string) [][2]string {
|
|
var nv [][2]string
|
|
for _, l := range lines(out) {
|
|
if f := strings.Fields(l); len(f) >= 2 {
|
|
nv = append(nv, [2]string{f[0], f[1]})
|
|
}
|
|
}
|
|
return nv
|
|
}
|
|
|
|
func nameSet(out string) map[string]bool {
|
|
s := map[string]bool{}
|
|
for _, l := range lines(out) {
|
|
s[strings.TrimSpace(l)] = true
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Installed is every installed package with its version, whether it was installed explicitly or as
|
|
// a dependency, and whether it is foreign (in no repository this machine syncs).
|
|
func (m *Machine) Installed(match, reason string, foreignOnly bool, limit int) (map[string]any, error) {
|
|
all, err := m.query("-Q")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
explicit, err := m.query("-Qeq")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
foreign, err := m.query("-Qmq")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ex, fo := nameSet(explicit), nameSet(foreign)
|
|
pkgs := []Package{}
|
|
counts := map[string]int{"explicit": 0, "dependency": 0, "foreign": 0}
|
|
for _, nv := range nameVersions(all) {
|
|
p := Package{Name: nv[0], Version: nv[1], Reason: "dependency", Foreign: fo[nv[0]]}
|
|
if ex[p.Name] {
|
|
p.Reason = "explicit"
|
|
}
|
|
counts[p.Reason]++
|
|
if p.Foreign {
|
|
counts["foreign"]++
|
|
}
|
|
if match != "" && !strings.Contains(p.Name, match) || reason != "" && p.Reason != reason || foreignOnly && !p.Foreign {
|
|
continue
|
|
}
|
|
pkgs = append(pkgs, p)
|
|
}
|
|
out := bound("packages", pkgs, limit)
|
|
out["totals"] = counts
|
|
return out, nil
|
|
}
|
|
|
|
var ownedBy = regexp.MustCompile(`^(.*) is owned by (\S+) (\S+)$`)
|
|
|
|
// Owns is which package owns a path.
|
|
func (m *Machine) Owns(p string) (map[string]any, error) {
|
|
if !path.IsAbs(p) {
|
|
return nil, fmt.Errorf("%q is not an absolute path", p)
|
|
}
|
|
r := m.Run(bg(), "pacman", "-Qo", "--", p)
|
|
if r.Status == 0 && r.Err == "" {
|
|
for _, l := range lines(r.Stdout) {
|
|
if o := ownedBy.FindStringSubmatch(l); o != nil {
|
|
return map[string]any{"path": o[1], "owned": true, "package": o[2], "version": o[3]}, nil
|
|
}
|
|
}
|
|
}
|
|
if r.Err == "" && strings.Contains(r.Stderr, "No package owns") {
|
|
return map[string]any{"path": p, "owned": false}, nil
|
|
}
|
|
return nil, failure("pacman", "pacman", r)
|
|
}
|
|
|
|
// Files is what an installed package placed, bounded.
|
|
func (m *Machine) Files(name string, limit int) (map[string]any, error) {
|
|
r := m.Run(bg(), "pacman", "-Ql", "--", name)
|
|
if r.Status != 0 || r.Err != "" {
|
|
if strings.Contains(r.Stderr, "was not found") {
|
|
return nil, fmt.Errorf("%s is not installed", name)
|
|
}
|
|
return nil, failure("pacman", "pacman", r)
|
|
}
|
|
paths := []string{}
|
|
for _, l := range lines(r.Stdout) {
|
|
if _, p, ok := strings.Cut(l, " "); ok {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
out := bound("paths", paths, limit)
|
|
out["package"] = name
|
|
return out, nil
|
|
}
|
|
|
|
// Orphans are packages installed as dependencies that nothing requires any more.
|
|
func (m *Machine) Orphans() (map[string]any, error) {
|
|
out, err := m.query("-Qdt")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pkgs := []map[string]string{}
|
|
for _, nv := range nameVersions(out) {
|
|
pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]})
|
|
}
|
|
return map[string]any{"count": len(pkgs), "orphans": pkgs}, nil
|
|
}
|
|
|
|
// Foreign is every installed package no repository this machine syncs carries: built from the AUR
|
|
// or by hand, which the host's `package` shape cannot install (research 027, question 1).
|
|
func (m *Machine) Foreign() (map[string]any, error) {
|
|
out, err := m.query("-Qm")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pkgs := []map[string]string{}
|
|
for _, nv := range nameVersions(out) {
|
|
pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]})
|
|
}
|
|
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i]["name"] < pkgs[j]["name"] })
|
|
return map[string]any{"count": len(pkgs), "packages": pkgs}, nil
|
|
}
|
|
|
|
// Update is one package an upgrade would change.
|
|
type Update struct {
|
|
Name string `json:"name"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
var updateLine = regexp.MustCompile(`^(\S+) (\S+) -> (\S+)`)
|
|
|
|
// Updates is what a full upgrade would change, from checkupdates: a copy of the sync databases
|
|
// refreshed apart from the machine's own, so asking never makes a partial upgrade possible.
|
|
func (m *Machine) Updates() (map[string]any, error) {
|
|
r := m.Run(bg(), "checkupdates")
|
|
switch {
|
|
case r.Err == "ENOENT":
|
|
return nil, fmt.Errorf("checkupdates is not installed: it comes with pacman-contrib, which this module declares")
|
|
case r.Err == "" && r.Status == 2:
|
|
return map[string]any{"count": 0, "updates": []Update{}}, nil
|
|
case r.Err != "" || r.Status != 0:
|
|
return nil, failure("checkupdates", "checkupdates", r)
|
|
}
|
|
ups := []Update{}
|
|
for _, l := range lines(r.Stdout) {
|
|
if u := updateLine.FindStringSubmatch(strings.TrimSpace(l)); u != nil {
|
|
ups = append(ups, Update{u[1], u[2], u[3]})
|
|
}
|
|
}
|
|
return map[string]any{"count": len(ups), "updates": ups}, nil
|
|
}
|
|
|
|
// Config is the configuration pacman runs with, as pacman-conf resolves it.
|
|
type Config struct {
|
|
Options map[string][]string `json:"options"`
|
|
Repositories []Repository `json:"repositories"`
|
|
MeshOwned bool `json:"mesh_owned"`
|
|
}
|
|
|
|
// Repository is one repository and where it is fetched from.
|
|
type Repository struct {
|
|
Name string `json:"name"`
|
|
Servers int `json:"servers"`
|
|
FirstServer string `json:"first_server,omitempty"`
|
|
SigLevel string `json:"sig_level,omitempty"`
|
|
}
|
|
|
|
// MeshHeader is how the module's pacman.conf begins, which is how it is recognised.
|
|
const MeshHeader = "# The mesh's (module pacman"
|
|
|
|
// ParseConf reads `pacman-conf`: [options] and each repository, with their values.
|
|
func ParseConf(out string) Config {
|
|
c := Config{Options: map[string][]string{}, Repositories: []Repository{}}
|
|
section := ""
|
|
for _, l := range lines(out) {
|
|
l = strings.TrimSpace(l)
|
|
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
|
|
section = strings.Trim(l, "[]")
|
|
if section != "options" {
|
|
c.Repositories = append(c.Repositories, Repository{Name: section})
|
|
}
|
|
continue
|
|
}
|
|
k, v, _ := strings.Cut(l, " = ")
|
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
|
if section == "options" {
|
|
c.Options[k] = append(c.Options[k], v)
|
|
continue
|
|
}
|
|
if n := len(c.Repositories); n > 0 {
|
|
r := &c.Repositories[n-1]
|
|
switch k {
|
|
case "Server":
|
|
if r.Servers == 0 {
|
|
r.FirstServer = v
|
|
}
|
|
r.Servers++
|
|
case "SigLevel":
|
|
r.SigLevel = strings.TrimSpace(r.SigLevel + " " + v)
|
|
}
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Conf is pacman's configuration in force, and whether /etc/pacman.conf is the module's.
|
|
func (m *Machine) Conf() (Config, error) {
|
|
out, err := m.Out("pacman-conf")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
c := ParseConf(out)
|
|
if text, err := m.ReadFile("/etc/pacman.conf"); err == nil {
|
|
c.MeshOwned = strings.HasPrefix(string(text), MeshHeader)
|
|
}
|
|
return c, nil
|
|
}
|