issue 1841/S9: carry out the upgrade's root acts through a root helper on a socket only the keeper opens, so no sudo rule lets the keeper's account run anything as root (ADR 0510)
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user