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) } }