Files
mesh-catalog/modules/pacman/cmd/pacman-tools/query.go
T
jochen d9336d11d0 pacman: the package manager's configuration, mirrors and cache as a module
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).
2026-10-04 12:50:20 +02:00

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
}