313 lines
14 KiB
Go
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)
|
|
}
|
|
}
|