Each node's reflector ranked its own list on its own timer, and on 2026-10-31 one node's lagging first mirror left it a Node.js version behind the rest. The list is now the module's state mirrors, key list: the node that opens a week's round ranks it with the module's reflector options, keeps only mirrors whose last sync is no older than the newest found, and the round's key names the list's revision. Every keeper writes /etc/pacman.d/mirrorlist whole from the key through the root helper's new verb mirrorlist, when the key changes and when the file differs, and never writes an empty or unreadable list. reflector.timer is declared stopped and disabled, and reflector.conf saves nothing. pacman_mirrors answers the shared list, its ranker and age and whether this node's file matches; with refresh it asks this node's keeper to rank again, refused while a round runs, naming it. The round's integration test proves the opening write and both nodes' files. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
327 lines
15 KiB
Go
327 lines
15 KiB
Go
package main
|
|
|
|
// The root helper (novox/hq ADR 0510; acceptance 1841/AC10 as reworded on the issue): each listed verb with valid
|
|
// arguments is carried out; every other verb, a unit name outside the module's own, a package name with other
|
|
// characters and a run of no run's shape are refused; a connection from an account other than the keeper's is
|
|
// refused; the manifest declares `pacman-root` as root with no bus and places nothing under the sudo
|
|
// configuration.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// rootBed is a helper over a recording runner, a stand-in process table and a plan directory of its own.
|
|
type rootBed struct {
|
|
h *RootHelper
|
|
ran [][]string
|
|
}
|
|
|
|
func newRootBed(t *testing.T) *rootBed {
|
|
b := &rootBed{}
|
|
b.h = &RootHelper{
|
|
Run: func(_ context.Context, name string, args ...string) Ran {
|
|
b.ran = append(b.ran, append([]string{name}, args...))
|
|
if name == "journalctl" {
|
|
return Ran{Stdout: "error: failed to commit transaction\n"}
|
|
}
|
|
return Ran{}
|
|
},
|
|
Proc: stubProc{"/proc": {"1"}, "/proc/1/maps": {"7f00 r-xp 0 0:1 1 /usr/lib/libc.so.6 (deleted)"}, "/proc/1/cgroup": {"0::/system.slice/sshd.service"}, "/proc/1/exe": {"/usr/bin/sshd"}},
|
|
ProcRoot: "/proc",
|
|
PlanDir: t.TempDir(),
|
|
Self: "/var/lib/mesh/daemons/pacman-root/pacman-tools",
|
|
MirrorlistPath: filepath.Join(t.TempDir(), "mirrorlist"),
|
|
}
|
|
return b
|
|
}
|
|
|
|
// stubProc is a process table in memory: a directory lists its names, a file holds its one line, a link its target.
|
|
type stubProc map[string][]string
|
|
|
|
func (s stubProc) ReadDir(dir string) ([]string, error) { return s[dir], nil }
|
|
func (s stubProc) ReadFile(name string) ([]byte, error) {
|
|
v, ok := s[name]
|
|
if !ok {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return []byte(strings.Join(v, "\n")), nil
|
|
}
|
|
func (s stubProc) Readlink(name string) (string, error) {
|
|
v, ok := s[name]
|
|
if !ok {
|
|
return "", os.ErrNotExist
|
|
}
|
|
return v[0], nil
|
|
}
|
|
|
|
const aRun = "20261024T040000Z"
|
|
|
|
// testSocket is a socket in the abstract namespace, named for the test: a path under a test's temporary
|
|
// directory can pass the 108 bytes a socket's path may hold.
|
|
func testSocket(t *testing.T) string {
|
|
return fmt.Sprintf("@pacman-root-test-%d-%s", os.Getpid(), t.Name())
|
|
}
|
|
|
|
func TestTheRootHelperStartsAnUpgradeAsTheRunsUnit(t *testing.T) {
|
|
b := newRootBed(t)
|
|
a := b.h.Handle(RootRequest{Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux", "linux-headers"}})
|
|
if a.Error != "" {
|
|
t.Fatalf("refused: %s", a.Error)
|
|
}
|
|
want := []string{"systemd-run", "--unit=" + TransactionUnit(RunUpgrade, aRun), "--description=pacman full upgrade of run " + aRun + ", started by the mesh's pacman keeper",
|
|
"--quiet", "pacman", "-Syu", "--noconfirm", "--ignore", "linux,linux-headers"}
|
|
if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) {
|
|
t.Fatalf("ran %q, want %q", b.ran, want)
|
|
}
|
|
}
|
|
|
|
func TestTheRootHelperStartsARollbackWithThePlanInTheRequest(t *testing.T) {
|
|
b := newRootBed(t)
|
|
plan := []RollbackItem{
|
|
{Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"},
|
|
{Name: "glibc", From: "2.43-2", To: "2.43-1", Source: SourceArchive, Location: ArchiveURL + "/packages/g/glibc/glibc-2.43-1-x86_64.pkg.tar.zst"},
|
|
{Name: "zlib", From: "1.3.2-1", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"},
|
|
{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved},
|
|
}
|
|
a := b.h.Handle(RootRequest{Verb: VerbRollback, Run: aRun, Plan: plan})
|
|
if a.Error != "" {
|
|
t.Fatalf("refused: %s", a.Error)
|
|
}
|
|
file := filepath.Join(b.h.PlanDir, aRun+".json")
|
|
want := []string{"systemd-run", "--unit=" + TransactionUnit(RunRollback, aRun), "--description=pacman rollback of run " + aRun + ", started by the mesh's pacman keeper",
|
|
"--quiet", b.h.Self, "root-rollback", file}
|
|
if len(b.ran) != 1 || !reflect.DeepEqual(b.ran[0], want) {
|
|
t.Fatalf("ran %q, want %q", b.ran, want)
|
|
}
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got []RollbackItem
|
|
if err := json.Unmarshal(raw, &got); err != nil || !reflect.DeepEqual(got, plan) {
|
|
t.Fatalf("the plan file holds %s (%v)", raw, err)
|
|
}
|
|
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
|
|
t.Errorf("the plan file is mode %v, want 0600: only root reads what root carries out", fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// And (ADR 0585 §3) the verb mirrorlist writes the mirror list file whole from the servers it is sent, running nothing.
|
|
func TestTheRootHelperReadsTheReplacedFilesMappings(t *testing.T) {
|
|
b := newRootBed(t)
|
|
servers := []string{"https://a.example/$repo/os/$arch", "https://b.example/archlinux/$repo/os/$arch"}
|
|
if a := b.h.Handle(RootRequest{Verb: VerbMirrorlist, Servers: servers}); a.Error != "" {
|
|
t.Fatalf("the mirror list refused: %s", a.Error)
|
|
}
|
|
if got, err := os.ReadFile(b.h.MirrorlistPath); err != nil || string(got) != string(RenderMirrorlist(servers)) || len(b.ran) != 0 {
|
|
t.Fatalf("the mirror list file holds %q (%v), ran %v", got, err, b.ran)
|
|
}
|
|
a := b.h.Handle(RootRequest{Verb: VerbMaps})
|
|
want := []Mapped{{Unit: "sshd.service", Executable: "/usr/bin/sshd"}}
|
|
if a.Error != "" || !reflect.DeepEqual(a.Mapped, want) {
|
|
t.Fatalf("maps answered %+v, want %+v", a, want)
|
|
}
|
|
}
|
|
|
|
func TestTheRootHelperReadsATransactionUnitsJournal(t *testing.T) {
|
|
b := newRootBed(t)
|
|
unit := TransactionUnit(RunUpgrade, aRun)
|
|
a := b.h.Handle(RootRequest{Verb: VerbJournal, Unit: unit, Lines: 40})
|
|
if a.Error != "" || !reflect.DeepEqual(a.Lines, []string{"error: failed to commit transaction"}) {
|
|
t.Fatalf("journal answered %+v", a)
|
|
}
|
|
want := []string{"journalctl", "--no-pager", "-o", "cat", "-n", "40", "-u", unit}
|
|
if !reflect.DeepEqual(b.ran[0], want) {
|
|
t.Fatalf("ran %q, want %q", b.ran[0], want)
|
|
}
|
|
}
|
|
|
|
func TestTheRootHelperRefusesEverythingOffItsList(t *testing.T) {
|
|
good := RollbackItem{Name: "bash", From: "5.3.20-1", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"}
|
|
with := func(f func(*RollbackItem)) []RollbackItem { i := good; f(&i); return []RollbackItem{i} }
|
|
cases := map[string]RootRequest{
|
|
"another verb": {Verb: "shell", Run: aRun},
|
|
"an empty verb": {},
|
|
"another kind of transaction": {Verb: VerbTransaction, Kind: "remove", Run: aRun},
|
|
"a rollback as a transaction": {Verb: VerbTransaction, Kind: RunRollback, Run: aRun},
|
|
"a run of no run's shape": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "../../etc"},
|
|
"a run with an option in it": {Verb: VerbTransaction, Kind: RunUpgrade, Run: "--now"},
|
|
"an ignored name with a space": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux headers"}},
|
|
"an ignored name as an option": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"--root=/tmp"}},
|
|
"an ignored name with a comma": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"linux,glibc"}},
|
|
"an ignored name in capitals": {Verb: VerbTransaction, Kind: RunUpgrade, Run: aRun, Ignore: []string{"Linux"}},
|
|
"a journal of another unit": {Verb: VerbJournal, Unit: "sshd.service", Lines: 10},
|
|
"a journal of a look-alike unit": {Verb: VerbJournal, Unit: "mesh-pacman-upgrade-x;reboot", Lines: 10},
|
|
"a journal of too many lines": {Verb: VerbJournal, Unit: TransactionUnit(RunUpgrade, aRun), Lines: 100000},
|
|
"a rollback of no plan": {Verb: VerbRollback, Run: aRun},
|
|
"a rollback of a bad run": {Verb: VerbRollback, Run: "x", Plan: []RollbackItem{good}},
|
|
"a plan item with a bad name": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Name = "bash;rm" })},
|
|
"a plan item with a bad version": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.To = "1 2" })},
|
|
"a cache file outside the cache": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = "/tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })},
|
|
"a cache file climbing out": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/../../../tmp/bash-5.3.15-1-x86_64.pkg.tar.zst" })},
|
|
"a cache file of another package": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Location = CacheDir + "/evil-1-1-x86_64.pkg.tar.zst" })},
|
|
"an archive URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
|
|
i.Source, i.Location = SourceArchive, "https://example.org/packages/b/bash/bash-5.3.15-1-x86_64.pkg.tar.zst"
|
|
})},
|
|
"a repository URL elsewhere": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
|
|
i.Source, i.Location = SourceRepository, ArchiveURL+"/repos/2026/10/04/../../../../x"
|
|
})},
|
|
"another source": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) { i.Source = "file" })},
|
|
"a removal with a location": {Verb: VerbRollback, Run: aRun, Plan: with(func(i *RollbackItem) {
|
|
i.Source, i.To = SourceRemoved, ""
|
|
})},
|
|
// ADR 0585 §3: never an empty mirror list, nor anything but a server in it.
|
|
"an empty mirror list": {Verb: VerbMirrorlist},
|
|
"a mirror list carrying a line": {Verb: VerbMirrorlist, Servers: []string{"https://a.example/$repo/os/$arch\nInclude = /tmp/x"}},
|
|
"a mirror list of a local file": {Verb: VerbMirrorlist, Servers: []string{"file:///tmp/$repo/os/$arch"}},
|
|
"a mirror list of another shape": {Verb: VerbMirrorlist, Servers: []string{"https://a.example/x"}},
|
|
}
|
|
for name, req := range cases {
|
|
b := newRootBed(t)
|
|
if a := b.h.Handle(req); a.Error == "" {
|
|
t.Errorf("%s: carried out (%+v)", name, a)
|
|
}
|
|
if len(b.ran) != 0 {
|
|
t.Errorf("%s: ran %q although refused", name, b.ran)
|
|
}
|
|
}
|
|
}
|
|
|
|
// serveOnce starts the helper on a socket of a test directory, allowing peer, and answers one request.
|
|
func serveOnce(t *testing.T, peer int, req RootRequest) (RootAnswer, error) {
|
|
t.Helper()
|
|
b := newRootBed(t)
|
|
b.h.Peer = peer
|
|
sock := testSocket(t)
|
|
l, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
go func() { _ = b.h.Serve(ctx, l) }()
|
|
return rootClient{Socket: sock}.ask(req)
|
|
}
|
|
|
|
func TestTheRootHelperAnswersTheKeepersAccountOverItsSocket(t *testing.T) {
|
|
a, err := serveOnce(t, os.Getuid(), RootRequest{Verb: VerbMaps})
|
|
if err != nil || a.Error != "" || len(a.Mapped) != 1 {
|
|
t.Fatalf("the keeper's own account was answered %+v, %v", a, err)
|
|
}
|
|
}
|
|
|
|
func TestTheRootHelperRefusesAnyOtherAccount(t *testing.T) {
|
|
a, err := serveOnce(t, os.Getuid()+1, RootRequest{Verb: VerbMaps})
|
|
if err == nil && a.Error == "" {
|
|
t.Fatalf("another account was answered: %+v", a)
|
|
}
|
|
if a.Mapped != nil {
|
|
t.Fatalf("another account was handed the mappings: %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestTheClientCarriesTheRootActsAndSaysARefusal(t *testing.T) {
|
|
b := newRootBed(t)
|
|
b.h.Peer = os.Getuid()
|
|
sock := testSocket(t)
|
|
l, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = b.h.Serve(ctx, l) }()
|
|
var acts RootActs = rootClient{Socket: sock}
|
|
if err := acts.Transaction(RunUpgrade, aRun, nil); err != nil {
|
|
t.Fatalf("transaction: %v", err)
|
|
}
|
|
if err := acts.Transaction(RunUpgrade, "bad", nil); err == nil || !strings.Contains(err.Error(), "run") {
|
|
t.Fatalf("a bad run was not refused by name: %v", err)
|
|
}
|
|
if lines, err := acts.Journal(TransactionUnit(RunUpgrade, aRun), 5); err != nil || len(lines) != 1 {
|
|
t.Fatalf("journal: %q, %v", lines, err)
|
|
}
|
|
if _, err := (rootClient{Socket: testSocket(t) + "-none"}).Maps(); err == nil || !strings.Contains(err.Error(), "root helper") {
|
|
t.Fatalf("a helper that is not there is not said: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheRootHelperIsRootWithNoBusAndNoSudoRule(t *testing.T) {
|
|
m := manifest(t)
|
|
r := m.resource(t, "root-helper")
|
|
if r["type"] != "process" || r["name"] != "pacman-root" || r["artifact"] != "tools" ||
|
|
strings.Join(strs(r["run"]), " ") != "./"+binaryName+" root" {
|
|
t.Fatalf("the root helper: %v", r)
|
|
}
|
|
if _, ok := r["user"]; ok {
|
|
t.Errorf("the root helper runs as root (ADR 0510 §1, §3), and names a user: %v", r["user"])
|
|
}
|
|
if _, ok := r["bus"]; ok {
|
|
t.Errorf("the root helper has no bus account (ADR 0510 §1): %v", r["bus"])
|
|
}
|
|
env, _ := r["env"].(map[string]any)
|
|
if env[EnvRootSocket] != RootSocketPath || env[EnvRootPeer] != "pacman-keeper" {
|
|
t.Errorf("the root helper's environment names its socket and the keeper's account: %v", env)
|
|
}
|
|
k := m.resource(t, "keeper")
|
|
kenv, _ := k["env"].(map[string]any)
|
|
if kenv[EnvRootSocket] != RootSocketPath {
|
|
t.Errorf("the keeper is told the helper's socket: %v", kenv)
|
|
}
|
|
for _, res := range m.Resources {
|
|
if p, _ := res["path"].(string); strings.Contains(p, "sudoers") {
|
|
t.Errorf("a file under the sudo configuration is placed: %s (ADR 0510 §1)", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheRollbackUnitInstallsThePlanThenRemovesWhatWasAskedInOrder(t *testing.T) {
|
|
dir := t.TempDir()
|
|
plan := []RollbackItem{
|
|
{Name: "bash", To: "5.3.15-1", Source: SourceCache, Location: CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"},
|
|
{Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"},
|
|
{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved},
|
|
}
|
|
raw, _ := json.Marshal(plan)
|
|
file := filepath.Join(dir, aRun+".json")
|
|
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ran [][]string
|
|
run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 }
|
|
probed := []string{}
|
|
probe := func(url string) bool { probed = append(probed, url); return strings.HasSuffix(url, "-any.pkg.tar.zst") }
|
|
if code := rollbackUnit(file, "x86_64", run, probe, os.Stderr); code != 0 {
|
|
t.Fatalf("exit %d", code)
|
|
}
|
|
want := [][]string{
|
|
{"pacman", "-U", "--noconfirm", "--", CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst", ArchiveURL + "/repos/2026/10/04/core/os/x86_64/zlib-1.3.1-2-any.pkg.tar.zst"},
|
|
{"pacman", "-R", "--noconfirm", "--", "made-up-library"},
|
|
}
|
|
if !reflect.DeepEqual(ran, want) {
|
|
t.Fatalf("ran %q, want %q", ran, want)
|
|
}
|
|
if len(probed) != 2 || !strings.HasSuffix(probed[0], "-x86_64.pkg.tar.zst") {
|
|
t.Errorf("the machine's architecture is tried first, then any: %q", probed)
|
|
}
|
|
}
|
|
|
|
func TestTheRollbackUnitChangesNothingWhenAPackageIsNotFound(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), aRun+".json")
|
|
raw, _ := json.Marshal([]RollbackItem{{Name: "zlib", To: "1.3.1-2", Source: SourceRepository, Location: ArchiveURL + "/repos/2026/10/04/core/os/$arch"}})
|
|
_ = os.WriteFile(file, raw, 0o600)
|
|
var ran [][]string
|
|
run := func(name string, args ...string) int { ran = append(ran, append([]string{name}, args...)); return 0 }
|
|
if code := rollbackUnit(file, "x86_64", run, func(string) bool { return false }, &strings.Builder{}); code == 0 || len(ran) != 0 {
|
|
t.Fatalf("exit %d, ran %q: a package the archive does not hold fails the unit before any change", code, ran)
|
|
}
|
|
}
|