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" "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 } return m } 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) } got := records(t, m) if len(got) != 2 || got[0].Path != "/etc/systemd/user/graphical-session.target.wants/greenclip.service" || got[1].Path != own || got[1].Target != "/usr/lib/systemd/user/greenclip.service" || got[1].Of != "default.target" || got[1].Why != "greenclip was replaced by clipmenu" || got[1].At != "2026-10-08T17:30:00Z" || got[1].Scope != "user" { t.Fatalf("%+v", got) } 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) } }