Files
mesh-catalog/modules/systemd/cmd/systemd-tools/unlink_test.go
T
jochen 1b368f9aaf
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Record a removal's outcome, publish it, and read one directory once
The record kept only the intent, so a failed unlink read as removed; each
link now gets a synced intent and outcome line, and the outcome is the
module's link.removal event so it is not only a file on one machine.
The put-back hint no longer advises a symlink, an aliased directory is
read once, a machine-wide removal from user scope is said, and the state
directory names no owner a machine without an account cannot resolve.
2026-10-08 17:55:56 +02:00

334 lines
13 KiB
Go

package main
// Removing a dangling enable link on the person's word (novox/hq issue 332), over the fake listing of
// wanted_test.go: what is removed and what is left, the record kept before each removal, and who removes.
import (
"bufio"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// leftovers is a machine with one removable dangling link and every kind that must be left.
func leftovers() tree {
user := home + "/.config/systemd/user"
return tree{
user: {{Name: "default.target.wants", Dir: true}},
user + "/default.target.wants": {
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // removed
{Name: "clip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // named for another unit
},
"/etc/systemd/user": {{Name: "graphical-session.target.wants", Dir: true}},
"/etc/systemd/user/graphical-session.target.wants": {
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // removed, as root
},
"/usr/lib/systemd/user": {{Name: "default.target.wants", Dir: true}},
"/usr/lib/systemd/user/default.target.wants": {{Name: "greenclip.service", Link: "../greenclip.service", Dangles: true}}, // a package's
}
}
func unlinker(t *testing.T, fsys tree, calls *[]call, removed *[]string) *Manager {
m := withTree(operator(fake(func(call) Ran { return Ran{} }, calls)), fsys)
m.State = t.TempDir()
m.Now = func() time.Time { return time.Date(2026, 10, 8, 17, 30, 0, 0, time.UTC) }
m.Remove = func(p string) error { *removed = append(*removed, p); return nil }
m.Emit = func(eventType string, body any) error {
emitted = append(emitted, event{eventType, body.(map[string]any)})
return nil
}
emitted = nil
return m
}
type event struct {
kind string
body map[string]any
}
// emitted is what the last unlinker published.
var emitted []event
func records(t *testing.T, m *Manager) []Unlinked {
f, err := os.Open(filepath.Join(m.State, UnlinkedRecord))
if err != nil {
t.Fatal(err)
}
defer f.Close()
var out []Unlinked
s := bufio.NewScanner(f)
for s.Scan() {
var r Unlinked
if err := json.Unmarshal(s.Bytes(), &r); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
return out
}
func TestUnlinkDanglingRemovesOnlyTheDanglingLinksNamedForTheUnitInOwnConfiguration(t *testing.T) {
var calls []call
var removed []string
m := unlinker(t, leftovers(), &calls, &removed)
answer, err := m.UnlinkDangling(User, "greenclip.service", "greenclip was replaced by clipmenu")
if err != nil {
t.Fatal(err)
}
own := home + "/.config/systemd/user/default.target.wants/greenclip.service"
if !reflect.DeepEqual(removed, []string{own}) {
t.Fatalf("removed by the account: %v", removed)
}
// The machine's own, outside the home, as root through sudo -n unlink; then the user manager reloaded.
var sudo, reload bool
for _, c := range calls {
if c.cmd == "sudo" && reflect.DeepEqual(c.args, []string{"-n", "unlink", "/etc/systemd/user/graphical-session.target.wants/greenclip.service"}) {
sudo = true
}
if c.cmd == "systemctl" && reflect.DeepEqual(c.args, []string{"--user", "daemon-reload"}) {
reload = sudo // after the removals
}
}
if !sudo || !reload {
t.Fatalf("%+v", calls)
}
// Two lines a link, intent then outcome, in the order they were removed.
got := records(t, m)
steps := []string{}
for _, r := range got {
steps = append(steps, r.Step+" "+r.Outcome+" "+r.Path)
}
wide := "/etc/systemd/user/graphical-session.target.wants/greenclip.service"
if !reflect.DeepEqual(steps, []string{"intent " + wide, "outcome removed " + wide, "intent " + own, "outcome removed " + own}) {
t.Fatalf("%q", steps)
}
if r := got[3]; r.Target != "/usr/lib/systemd/user/greenclip.service" || r.Of != "default.target" || r.MachineWide ||
r.Why != "greenclip was replaced by clipmenu" || r.At != "2026-10-08T17:30:00Z" || r.Scope != "user" || !got[1].MachineWide {
t.Fatalf("%+v", got)
}
// Each outcome published as the module's event, with the names and the reason and nothing else.
if len(emitted) != 2 || emitted[1].kind != RemovalEvent || emitted[1].body["path"] != own ||
emitted[1].body["outcome"] != "removed" || emitted[1].body["why"] != "greenclip was replaced by clipmenu" ||
emitted[1].body["unit"] != "greenclip.service" || emitted[1].body["target"] != "/usr/lib/systemd/user/greenclip.service" {
t.Fatalf("%+v", emitted)
}
// A user-scope call that removed a machine-wide link says so.
if w, _ := answer["machine_wide"].([]string); !reflect.DeepEqual(w, []string{wide}) || answer["machine_wide_note"] == nil {
t.Fatalf("%v", answer)
}
if next, _ := answer["next"].(string); strings.Contains(next, "ln -s") || !strings.Contains(next, "node-service-manager.enable") {
t.Fatalf("%q", next)
}
if answer["ok"] != true {
t.Fatalf("%v", answer)
}
left := answer["left"].([]map[string]string)
why := map[string]string{}
for _, l := range left {
why[l["path"]] = l["why_left"]
}
if !strings.Contains(why[home+"/.config/systemd/user/default.target.wants/clip.service"], "named for another unit") ||
!strings.Contains(why["/usr/lib/systemd/user/default.target.wants/greenclip.service"], "a package's") {
t.Fatalf("%v", why)
}
}
func TestUnlinkDanglingLeavesALinkWhoseTargetExistsOrThatChanged(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: "/usr/lib/systemd/user/x.service"}},
}
var removed []string
m := unlinker(t, fsys, nil, &removed)
answer, err := m.UnlinkDangling(User, "x.service", "test")
if err != nil || len(removed) != 0 || !strings.Contains(answer["left"].([]map[string]string)[0]["why_left"], "`disable`") {
t.Fatalf("%v %v %v", answer, err, removed)
}
// Found dangling, then pointed elsewhere before it is removed.
fsys[user+"/default.target.wants"] = []Entry{{Name: "x.service", Link: "/gone/x.service", Dangles: true}}
m = unlinker(t, fsys, nil, &removed)
m.Lstat = func(string) (Entry, error) {
return Entry{Name: "x.service", Link: "/usr/lib/systemd/user/x.service"}, nil
}
answer, err = m.UnlinkDangling(User, "x.service", "test")
if err != nil || len(removed) != 0 || answer["left"].([]map[string]string)[0]["why_left"] != "it changed since it was read" {
t.Fatalf("%v %v %v", answer, err, removed)
}
if _, err := os.Stat(filepath.Join(m.State, UnlinkedRecord)); !os.IsNotExist(err) {
t.Fatal("a record was kept of a link that was not removed")
}
}
func TestUnlinkDanglingNeedsAReasonAndAPlaceForTheRecord(t *testing.T) {
var removed []string
m := unlinker(t, leftovers(), nil, &removed)
if _, err := m.UnlinkDangling(User, "greenclip.service", " "); err == nil || !strings.Contains(err.Error(), "why is required") {
t.Fatalf("%v", err)
}
m.State = ""
if _, err := m.UnlinkDangling(User, "greenclip.service", "x"); err == nil {
t.Fatal("removed with nowhere to record")
}
// A record that cannot be written: nothing is removed.
file := filepath.Join(t.TempDir(), "a-file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
m.State = file
if _, err := m.UnlinkDangling(User, "greenclip.service", "x"); err == nil || !strings.Contains(err.Error(), "not removed") || len(removed) != 0 {
t.Fatalf("%v %v", err, removed)
}
}
func TestUnlinkDanglingInTheSystemScopeGoesThroughSudo(t *testing.T) {
fsys := tree{
"/etc/systemd/system": {{Name: "multi-user.target.wants", Dir: true}},
"/etc/systemd/system/multi-user.target.wants": {{Name: "old.service", Link: "/usr/lib/systemd/system/old.service", Dangles: true}},
"/run/systemd/generator": {{Name: "local-fs.target.wants", Dir: true}},
"/run/systemd/generator/local-fs.target.wants": {{Name: "old.service", Link: "/gone", Dangles: true}},
}
var calls []call
var removed []string
m := unlinker(t, fsys, &calls, &removed)
answer, err := m.UnlinkDangling(System, "old.service", "the package is gone")
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("removed without sudo: %v", removed)
}
if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "unlink", "/etc/systemd/system/multi-user.target.wants/old.service"}) {
t.Fatalf("%v", got)
}
if got := append([]string{calls[1].cmd}, calls[1].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "daemon-reload"}) {
t.Fatalf("%v", got)
}
if len(answer["removed"].([]Unlinked)) != 1 || len(answer["left"].([]map[string]string)) != 1 {
t.Fatalf("%v", answer)
}
}
// **The record never claims a removal that failed** (the re-review of #138): the intent is on disk before the
// link is touched, and the outcome after it says it was not removed and why — in the record, the event and
// the answer alike.
func TestAnUnlinkThatFailsAfterTheIntentIsRecordedAsNotRemoved(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: "/gone/x.service", Dangles: true}},
}
var calls []call
var removed []string
m := unlinker(t, fsys, &calls, &removed)
m.Remove = func(p string) error {
// The intent is already on disk when the removal is tried.
if got := records(t, m); len(got) != 1 || got[0].Step != "intent" {
t.Fatalf("removed before the intent was kept: %+v", got)
}
return fs.ErrPermission
}
answer, err := m.UnlinkDangling(User, "x.service", "test")
if err != nil {
t.Fatal(err)
}
got := records(t, m)
if len(got) != 2 || got[1].Step != "outcome" || !strings.HasPrefix(got[1].Outcome, "not removed: ") || !strings.Contains(got[1].Outcome, "permission denied") {
t.Fatalf("%+v", got)
}
if answer["ok"] != false || len(answer["removed"].([]Unlinked)) != 0 || len(answer["not_removed"].([]Unlinked)) != 1 {
t.Fatalf("%v", answer)
}
if len(emitted) != 1 || !strings.HasPrefix(emitted[0].body["outcome"].(string), "not removed: ") {
t.Fatalf("%+v", emitted)
}
for _, c := range calls {
if contains(c.args, "daemon-reload") {
t.Fatal("reloaded with nothing removed")
}
}
}
// An event that cannot be published is said, and the removal and its record stand.
func TestAnUnpublishedOutcomeIsSaid(t *testing.T) {
var removed []string
m := unlinker(t, leftovers(), nil, &removed)
m.Emit = func(string, any) error { return errors.New("no bus") }
answer, err := m.UnlinkDangling(User, "greenclip.service", "test")
if err != nil {
t.Fatal(err)
}
if u, _ := answer["unpublished"].([]string); len(u) != 2 || len(removed) != 1 {
t.Fatalf("%v %v", answer, removed)
}
}
// A dependency directory that is itself a link is never removed through, even into the searched
// directories: such a link is listed by wanted-by, and left by unlink-dangling.
func TestUnlinkDanglingRefusesALinkReachedThroughALinkedDependencyDirectory(t *testing.T) {
user := home + "/.config/systemd/user"
fsys := tree{
user: {{Name: "default.target.wants", Link: "/etc/systemd/user/shared.wants"}},
"/etc/systemd/user/shared.wants": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}},
}
var calls []call
var removed []string
m := unlinker(t, fsys, &calls, &removed)
answer, err := m.UnlinkDangling(User, "x.service", "test")
if err != nil {
t.Fatal(err)
}
left := answer["left"].([]map[string]string)
if len(removed) != 0 || len(calls) != 0 || len(left) != 1 || !strings.Contains(left[0]["why_left"], "reached through a linked directory") {
t.Fatalf("%v %v %v", answer, removed, calls)
}
}
// One directory reached by two names (Arch's /etc/xdg/systemd/user leads to /etc/systemd/user) is read
// once: its link is listed once and removed once.
func TestADirectoryReachedByTwoNamesIsReadOnce(t *testing.T) {
fsys := tree{
"/etc/systemd/user": {{Name: "default.target.wants", Dir: true}},
"/etc/systemd/user/default.target.wants": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}},
"/etc/xdg/systemd/user": {{Name: "default.target.wants", Dir: true}},
"/etc/xdg/systemd/user/default.target.wants": {{Name: "x.service", Link: "/gone/x.service", Dangles: true}},
}
var calls []call
var removed []string
m := unlinker(t, fsys, &calls, &removed)
m.Resolve = func(p string) (string, error) {
if p == "/etc/xdg/systemd/user" {
return "/etc/systemd/user", nil
}
return p, nil
}
answer, err := m.WantedBy(User, "x.service")
if err != nil {
t.Fatal(err)
}
if links := answer["links"].([]EnableLink); len(links) != 1 || links[0].Path != "/etc/systemd/user/default.target.wants/x.service" {
t.Fatalf("%+v", links)
}
if same, _ := answer["same_directory"].([]string); len(same) != 1 || answer["unread"] != nil {
t.Fatalf("%v", answer)
}
if _, err := m.UnlinkDangling(User, "x.service", "test"); err != nil {
t.Fatal(err)
}
unlinks := 0
for _, c := range calls {
if c.cmd == "sudo" && contains(c.args, "unlink") {
unlinks++
}
}
if unlinks != 1 {
t.Fatalf("removed %d times: %+v", unlinks, calls)
}
}