Record a removal's outcome, publish it, and read one directory once
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

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:
jochen
2026-10-08 17:55:56 +02:00
parent 87c9d9f7e5
commit 1b368f9aaf
6 changed files with 286 additions and 29 deletions
@@ -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)
}
}