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) } }