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.
This commit is contained in:
@@ -6,6 +6,8 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -37,9 +39,22 @@ func unlinker(t *testing.T, fsys tree, calls *[]call, removed *[]string) *Manage
|
||||
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 {
|
||||
@@ -83,12 +98,36 @@ func TestUnlinkDanglingRemovesOnlyTheDanglingLinksNamedForTheUnitInOwnConfigurat
|
||||
if !sudo || !reload {
|
||||
t.Fatalf("%+v", calls)
|
||||
}
|
||||
// Two lines a link, intent then outcome, in the order they were removed.
|
||||
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" {
|
||||
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 {
|
||||
@@ -175,3 +214,120 @@ func TestUnlinkDanglingInTheSystemScopeGoesThroughSudo(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user