Files
mesh-catalog/modules/pacman/cmd/pacman-tools/root_test.go
T

313 lines
14 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",
}
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())
}
}
func TestTheRootHelperReadsTheReplacedFilesMappings(t *testing.T) {
b := newRootBed(t)
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, ""
})},
}
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)
}
}