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.
334 lines
13 KiB
Go
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)
|
|
}
|
|
}
|