Files
mesh-catalog/modules/systemd/cmd/systemd-tools/wanted_test.go
T
jochen 87c9d9f7e5 Serve unlink-dangling, so a leftover enable link goes through the seat
systemctl disable leaves a link whose unit file is gone, and the manager
keeps asking for the unit. The verb removes only such links in the
account's or the machine's own configuration, on the person's word, and
records each with its reason before removing it (hq issue 332).
2026-10-08 17:53:53 +02:00

457 lines
17 KiB
Go

package main
// What wants a unit, and a failed record cleared (novox/hq issue 332): over a fake runner and a fake
// directory listing, so no link is made to test them (the mesh makes no symbolic links, its tests
// included); the real listing is tested on what a temporary directory holds without one.
import (
"errors"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// tree is a fake file system of directories, each a list of entries; a directory not in it does not exist.
type tree map[string][]Entry
func (t tree) readDir(dir string) ([]Entry, error) {
if e, ok := t[dir]; ok {
if e == nil {
return nil, fs.ErrPermission
}
return e, nil
}
return nil, fs.ErrNotExist
}
const home = "/home/operator"
func withTree(m *Manager, t tree) *Manager {
m.Home, m.ReadDir = home, t.readDir
m.Lstat = func(path string) (Entry, error) {
for _, e := range t[filepath.Dir(path)] {
if e.Name == filepath.Base(path) {
return e, nil
}
}
return Entry{}, fs.ErrNotExist
}
return m
}
// The leftover of a removed package: its file is gone, an enable link in the account's own configuration
// still names it, and the manager still has default.target wanting it.
func greenclip() tree {
user := home + "/.config/systemd/user"
return tree{
user: {{Name: "default.target.wants", Dir: true}, {Name: "timers.target.wants", Dir: true}, {Name: "clip.service"}},
user + "/default.target.wants": {
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true},
{Name: "pipewire.service", Link: "/usr/lib/systemd/user/pipewire.service"},
},
user + "/timers.target.wants": {{Name: "backup.timer", Link: "/usr/lib/systemd/user/backup.timer"}},
"/usr/lib/systemd/user": {{Name: "pipewire.service"}, {Name: "sockets.target.wants", Dir: true}},
"/usr/lib/systemd/user/sockets.target.wants": {{Name: "pipewire.socket", Link: "../pipewire.socket"}},
}
}
func showAndReverse(show, reverse string) func(c call) Ran {
return func(c call) Ran {
for _, a := range c.args {
if a == "show" {
return Ran{Stdout: show}
}
if a == "list-dependencies" {
return Ran{Stdout: reverse}
}
}
return Ran{}
}
}
func TestWantedByFindsADanglingEnableLinkInTheAccountsOwnConfiguration(t *testing.T) {
var calls []call
run := fake(showAndReverse(
"LoadState=not-found\nActiveState=failed\nSubState=failed\nUnitFileState=\nFragmentPath=\nWantedBy=default.target\nRequiredBy=\nTriggeredBy=\n",
"greenclip.service\n default.target\n"), &calls)
answer, err := withTree(operator(run), greenclip()).WantedBy(User, "greenclip.service")
if err != nil {
t.Fatal(err)
}
links := answer["links"].([]EnableLink)
want := []EnableLink{{Path: home + "/.config/systemd/user/default.target.wants/greenclip.service", Of: "default.target",
Dependency: "Wants", Target: "/usr/lib/systemd/user/greenclip.service", Dangles: true, Link: true,
Whose: "the operator account's own configuration", removable: true}}
if !reflect.DeepEqual(links, want) {
t.Fatalf("links %+v", links)
}
manager := answer["manager"].(map[string]any)
if !reflect.DeepEqual(manager["WantedBy"], []string{"default.target"}) || manager["LoadState"] != "not-found" {
t.Fatalf("manager %+v", manager)
}
if _, empty := manager["RequiredBy"]; empty {
t.Fatal("an empty property is answered as a dependency")
}
if !reflect.DeepEqual(manager["reverse"], []string{"default.target"}) {
t.Fatalf("reverse %v", manager["reverse"])
}
said := strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "default.target.wants/greenclip.service makes default.target want greenclip.service") ||
!strings.Contains(said, "which does not exist") || !strings.Contains(said, "reset-failed") {
t.Fatalf("findings %q", said)
}
if _, has := answer["unread"]; has {
t.Fatalf("a directory that does not exist was said as unread: %v", answer["unread"])
}
// Read-only: the manager is only asked.
for _, c := range calls {
if c.args[1] != "show" && c.args[1] != "list-dependencies" {
t.Fatalf("WantedBy ran %v", c.args)
}
if c.args[0] != "--user" {
t.Fatalf("a user-scope question went to the system manager: %v", c.args)
}
}
// The unit comes after "--", so a name that slipped past would still not be an option.
last := calls[len(calls)-1].args
if last[len(last)-2] != "--" {
t.Fatalf("%v", last)
}
}
func TestWantedByMatchesALinkByWhatItPointsAt(t *testing.T) {
user := home + "/.config/systemd/user"
fsys := tree{
user: {{Name: "graphical-session.target.requires", Dir: true}},
user + "/graphical-session.target.requires": {{Name: "clipboard.service", Link: "/usr/lib/systemd/user/greenclip.service"}},
}
answer, err := withTree(operator(fake(showAndReverse("LoadState=loaded\n", "greenclip.service\n"), nil)), fsys).WantedBy(User, "greenclip.service")
if err != nil {
t.Fatal(err)
}
links := answer["links"].([]EnableLink)
if len(links) != 1 || links[0].Dependency != "Requires" || links[0].Of != "graphical-session.target" || links[0].Dangles {
t.Fatalf("%+v", links)
}
}
func TestWantedBySaysWhenNothingNamesTheUnit(t *testing.T) {
answer, err := withTree(operator(fake(showAndReverse("LoadState=not-found\nActiveState=failed\nWantedBy=\n", "gone.service\n"), nil)), greenclip()).WantedBy(User, "gone.service")
if err != nil {
t.Fatal(err)
}
if len(answer["links"].([]EnableLink)) != 0 {
t.Fatalf("%v", answer["links"])
}
said := strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "nothing names gone.service in the directories searched:") {
t.Fatalf("%q", said)
}
}
func TestWantedBySaysAnUnreadableDirectoryAndAManagerThatDoesNotAnswer(t *testing.T) {
fsys := greenclip()
fsys["/etc/systemd/user"] = nil // unreadable
run := fake(func(call) Ran { return Ran{Status: 1, Stderr: "Failed to connect to bus: No such file or directory"} }, nil)
answer, err := withTree(operator(run), fsys).WantedBy(User, "greenclip.service")
if err != nil {
t.Fatalf("an unreachable manager failed the whole answer: %v", err)
}
manager := answer["manager"].(map[string]any)
if e, _ := manager["error"].(string); !strings.Contains(e, "does not answer") {
t.Fatalf("%+v", manager)
}
if len(answer["links"].([]EnableLink)) != 1 {
t.Fatal("the links on disk were not read without the manager")
}
unread, _ := answer["unread"].([]string)
if len(unread) != 1 || !strings.HasPrefix(unread[0], "/etc/systemd/user: ") {
t.Fatalf("%v", unread)
}
said := strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "only the links on disk are known") || !strings.Contains(said, "could not be read") {
t.Fatalf("%q", said)
}
if strings.Contains(said, "nothing names") {
t.Fatal("an incomplete answer said nothing names the unit")
}
}
func TestWantedByInTheSystemScopeReadsTheMachinesDirectoriesUnescalated(t *testing.T) {
var calls []call
fsys := tree{
"/etc/systemd/system": {{Name: "multi-user.target.wants", Dir: true}},
"/etc/systemd/system/multi-user.target.wants": {{Name: "sshd.service", Link: "/usr/lib/systemd/system/sshd.service"}},
}
m := withTree(operator(fake(showAndReverse("LoadState=loaded\nWantedBy=multi-user.target\n", "sshd.service\nmulti-user.target\ngraphical.target\n"), &calls)), fsys)
answer, err := m.WantedBy(System, "sshd.service")
if err != nil {
t.Fatal(err)
}
for _, c := range calls {
if c.cmd != "systemctl" {
t.Fatalf("a read was escalated: %s %v", c.cmd, c.args)
}
}
links := answer["links"].([]EnableLink)
if len(links) != 1 || links[0].Whose != "the machine's configuration" {
t.Fatalf("%+v", links)
}
if !reflect.DeepEqual(answer["manager"].(map[string]any)["reverse"], []string{"multi-user.target", "graphical.target"}) {
t.Fatalf("%v", answer["manager"])
}
for _, d := range answer["searched"].([]ConfigDir) {
if strings.HasPrefix(d.Path, home) {
t.Fatal("the system scope searched the account's home")
}
}
}
func TestWantedByRefusesAUnitThatReadsAsAnOption(t *testing.T) {
m := withTree(operator(fake(func(call) Ran { t.Fatal("ran"); return Ran{} }, nil)), tree{})
if _, err := m.WantedBy(User, "--all"); err == nil {
t.Fatal("an option was taken as a unit")
}
}
func TestResetFailedClearsOneUnitEscalatedOnlyInTheSystemScope(t *testing.T) {
if p, a := escalated("systemctl", []string{"reset-failed", "x.mount"}, System, 1000); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "systemctl", "reset-failed", "x.mount"}) {
t.Fatalf("%s %v", p, a)
}
var calls []call
m := operator(fake(func(c call) Ran {
if c.args[1] == "show" {
return Ran{Stdout: "LoadState=not-found\nActiveState=inactive\nSubState=dead\nUnitFileState=\nFragmentPath=\n"}
}
return Ran{}
}, &calls))
var reset stdioTool
for _, tool := range tools(m) {
if tool.Name == seat+".reset-failed" {
reset = stdioTool{tool.Run}
}
}
if reset.run == nil {
t.Fatal("reset-failed is not served")
}
out, err := reset.run(map[string]any{"unit": "greenclip.service", "scope": "user"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(calls[0].args, []string{"--user", "reset-failed", "--", "greenclip.service"}) || calls[0].cmd != "systemctl" {
t.Fatalf("%s %v", calls[0].cmd, calls[0].args)
}
answer := out.(map[string]any)
if answer["active"] != "inactive" || answer["verb"] != "reset-failed" {
t.Fatalf("%v", answer)
}
if _, err := reset.run(map[string]any{"scope": "user"}); err == nil {
t.Fatal("reset-failed without a unit would reset every failed unit")
}
if _, err := reset.run(map[string]any{"unit": "-a", "scope": "user"}); err == nil {
t.Fatal("an option was taken as a unit")
}
}
type stdioTool struct {
run func(map[string]any) (any, error)
}
// **A glob is not a unit** (the review of mesh-catalog #137): systemctl expands it to every loaded unit it
// matches, so `reset-failed` or `stop` of "*" would act on all of them. Refused before anything runs, for
// every verb taking a unit.
func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) {
for _, bad := range []string{"*", "*.service", "foo?.service", "a[bc].mount"} {
if unitArg(bad) == nil {
t.Errorf("%q accepted", bad)
}
}
var calls []call
m := withTree(operator(fake(func(call) Ran { return Ran{} }, &calls)), tree{})
m.State = t.TempDir()
for _, tool := range tools(m) {
verb := strings.TrimPrefix(tool.Name, seat+".")
if verb == "units" || verb == "failed" {
continue // a pattern, and no unit
}
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
if err == nil {
t.Errorf("%s accepted {\"unit\":\"*\"}", verb)
}
}
if len(calls) != 0 {
t.Fatalf("a glob reached the manager: %v", calls)
}
}
// The unit is given after "--" to show as well as to an act, so nothing after it is read as an option.
func TestTheUnitComesAfterTheEndOfOptions(t *testing.T) {
var calls []call
m := operator(fake(func(call) Ran { return Ran{Stdout: "LoadState=loaded\n"} }, &calls))
if _, err := m.Status(System, "sshd.service"); err != nil {
t.Fatal(err)
}
a := calls[0].args
if a[0] != "show" || a[len(a)-2] != "--" || a[len(a)-1] != "sshd.service" {
t.Fatalf("%v", a)
}
}
// A target that could not be looked at, for any reason but its not existing, is neither dangling nor not:
// it is said as the error it was, and the answer does not say nothing names the unit.
func TestALinkWhoseTargetCannotBeLookedAtIsNotSaidToDangle(t *testing.T) {
user := home + "/.config/systemd/user"
fsys := tree{
user: {{Name: "default.target.wants", Dir: true}},
user + "/default.target.wants": {{Name: "x.service", Link: "/mnt/away/x.service", Error: "stat /mnt/away/x.service: permission denied"}},
}
answer, err := withTree(operator(fake(showAndReverse("LoadState=loaded\n", "x.service\n"), nil)), fsys).WantedBy(User, "x.service")
if err != nil {
t.Fatal(err)
}
links := answer["links"].([]EnableLink)
if len(links) != 1 || links[0].Dangles || links[0].Error == "" {
t.Fatalf("%+v", links)
}
said := strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "could not be looked at: stat /mnt/away/x.service: permission denied") || strings.Contains(said, "does not exist") {
t.Fatalf("%q", said)
}
}
// A *.wants directory that is itself a link is followed only into the directories searched: one leading
// elsewhere is said and not read, so neither the search nor a removal after it can be pointed anywhere.
func TestALinkedDependencyDirectoryIsFollowedOnlyIntoTheSearchedDirectories(t *testing.T) {
user := home + "/.config/systemd/user"
fsys := tree{
user: {
{Name: "default.target.wants", Link: "/srv/elsewhere"},
{Name: "graphical-session.target.wants", Link: "/etc/systemd/user/shared.wants"},
},
"/srv/elsewhere": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}},
"/etc/systemd/user/shared.wants": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}},
}
read := []string{}
m := withTree(operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil)), fsys)
inner := m.ReadDir
m.ReadDir = func(dir string) ([]Entry, error) { read = append(read, dir); return inner(dir) }
answer, err := m.WantedBy(User, "x.service")
if err != nil {
t.Fatal(err)
}
for _, d := range read {
if d == "/srv/elsewhere" {
t.Fatal("a linked .wants outside the searched directories was followed")
}
}
links := answer["links"].([]EnableLink)
if len(links) != 1 || links[0].Path != user+"/graphical-session.target.wants/x.service" || links[0].removable {
t.Fatalf("%+v", links)
}
unread, _ := answer["unread"].([]string)
if len(unread) != 1 || !strings.Contains(unread[0], "outside the directories searched, not followed") {
t.Fatalf("%v", unread)
}
}
// "Nothing names it" is only ever said of the directories searched, and the account's own are not among
// them when its home is not known — which is said.
func TestNothingNamesItIsSaidOfTheDirectoriesSearchedAndTheHomeWhenUnknown(t *testing.T) {
m := withTree(operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil)), tree{})
m.Home = ""
answer, err := m.WantedBy(User, "x.service")
if err != nil {
t.Fatal(err)
}
for _, d := range answer["searched"].([]ConfigDir) {
if strings.Contains(d.Path, ".config") || strings.Contains(d.Path, ".local") || d.Path == "" {
t.Fatalf("searched %q without a home", d.Path)
}
}
said := strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "nothing names x.service in the directories searched, which are not all of them") ||
!strings.Contains(said, "the account's home is not known") {
t.Fatalf("%q", said)
}
m.Home = home
answer, _ = m.WantedBy(User, "x.service")
said = strings.Join(answer["findings"].([]string), "\n")
if !strings.Contains(said, "nothing names x.service in the directories searched:") {
t.Fatalf("%q", said)
}
}
// Every directory systemd reads units from, in both scopes (systemd.unit(5)).
func TestTheSearchedDirectoriesAreTheManagersLoadPath(t *testing.T) {
m := withTree(operator(nil), tree{})
paths := func(s Scope) string {
var out []string
for _, d := range m.configDirs(s) {
out = append(out, d.Path)
}
return strings.Join(out, "\n")
}
for _, want := range []string{"/etc/systemd/system.control", "/run/systemd/transient", "/run/systemd/generator.early",
"/etc/systemd/system", "/run/systemd/system", "/run/systemd/generator", "/usr/local/lib/systemd/system",
"/usr/lib/systemd/system", "/run/systemd/generator.late"} {
if !strings.Contains(paths(System)+"\n", want+"\n") {
t.Errorf("system scope does not search %s", want)
}
}
for _, want := range []string{home + "/.config/systemd/user.control", "/run/user/1000/systemd/user", home + "/.config/systemd/user",
"/etc/systemd/user", "/etc/xdg/systemd/user", home + "/.local/share/systemd/user", "/usr/local/lib/systemd/user", "/usr/lib/systemd/user"} {
if !strings.Contains(paths(User)+"\n", want+"\n") {
t.Errorf("user scope does not search %s", want)
}
}
}
// The real listing, by lstat, on what a temporary directory holds: a directory, a file, and a directory
// that does not exist or cannot be read.
func TestReadDirListsByLstat(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "default.target.wants"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "x.service"), []byte("[Unit]\n"), 0o644); err != nil {
t.Fatal(err)
}
entries, err := readDir(dir)
if err != nil {
t.Fatal(err)
}
want := []Entry{{Name: "default.target.wants", Dir: true}, {Name: "x.service"}}
if !reflect.DeepEqual(entries, want) {
t.Fatalf("%+v", entries)
}
if e, err := lstatEntry(filepath.Join(dir, "x.service")); err != nil || e.Link != "" || e.Dangles || e.Dir {
t.Fatalf("%+v %v", e, err)
}
if _, err := readDir(filepath.Join(dir, "absent")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("a missing directory: %v", err)
}
if os.Getuid() != 0 {
locked := filepath.Join(dir, "locked")
if err := os.Mkdir(locked, 0o000); err != nil {
t.Fatal(err)
}
defer os.Chmod(locked, 0o755)
if _, err := readDir(locked); err == nil || errors.Is(err, fs.ErrNotExist) {
t.Fatalf("an unreadable directory read as %v", err)
}
}
// And through the search: a real tree with no link in it names nothing, and says so.
m := operator(fake(showAndReverse("LoadState=not-found\n", "x.service\n"), nil))
m.Home, m.ReadDir = dir, readDir
answer, err := m.WantedBy(User, "x.service")
if err != nil {
t.Fatal(err)
}
if len(answer["links"].([]EnableLink)) != 0 {
t.Fatalf("%v", answer["links"])
}
}