package main // What wants, requires or triggers one unit (novox/hq issue 332), read-only. Two sources, because each misses // what the other sees: // // - **the manager**, asked for the unit's reverse dependencies (systemctl show's WantedBy, RequiredBy and // the rest, and list-dependencies --reverse for what pulls it in through another unit). It knows what is // loaded now, including a dependency a unit file states in its own [Unit] section; // - **the configuration directories**, read for an enable link naming the unit in a *.wants, *.requires or // *.upholds directory. A link outlives the unit file it pointed at: once the package that shipped the unit // is gone, the link dangles, and the manager asks for a unit it cannot find at every boot or login. That // is the case a failed record of a unit whose load state is not-found comes from, and the answer says // each link's target and whether it dangles. // // Nothing here changes anything. Removing a dangling link is unlink.go's; clearing the record is // `reset-failed`'s. import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" ) // reverseProperties are the unit's properties that name a unit depending on it. var reverseProperties = []string{"WantedBy", "RequiredBy", "RequisiteOf", "BoundBy", "UpheldBy", "ConsistsOf", "TriggeredBy", "PartOf"} // stateProperties say what the manager knows of the unit itself, so a not-found unit is recognised. var stateProperties = []string{"LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath"} // dependencyDirs are the suffixes of the directories an enable link sits in, and the dependency each makes. var dependencyDirs = map[string]string{".wants": "Wants", ".requires": "Requires", ".upholds": "Upholds"} // Entry is one name in a directory as lstat sees it: a link is never followed to say what it is. type Entry struct { Name string // Dir is whether the name is itself a directory; a link to one is not. Dir bool // Link is where a symbolic link points, as written; empty when the name is not a link. Link string // Dangles is whether a link points at nothing: what it names does not exist. Dangles bool // Error is why a link's target could not be looked at, when that is anything but its not existing. Error string } // ConfigDir is one directory a manager reads units and enable links from, and whose it is. type ConfigDir struct { Path string `json:"path"` Whose string `json:"whose"` // Removable is whether a dangling link here is a person's or an administrator's to remove: their own // configuration, not a package's, a generator's or the runtime's, which put theirs back. Removable bool `json:"-"` } // EnableLink is one link in a dependency directory that names the unit. type EnableLink struct { Path string `json:"path"` // Of is the unit that wants (or requires, or upholds) this one: the directory's name less its suffix. Of string `json:"of"` Dependency string `json:"dependency"` Target string `json:"target,omitempty"` Dangles bool `json:"dangles"` // Error is why the target could not be looked at, other than its not existing: neither dangling nor not. Error string `json:"error,omitempty"` // Link is false for a plain file in a dependency directory, which the manager reads the same way. Link bool `json:"link"` Whose string `json:"whose"` removable bool } // configDirs are the directories a scope's manager reads unit files and enable links from, in its own order // of precedence (systemd.unit(5), "Unit File Load Path"). The account's own are left out, and said, when its // home is not known. func (m *Manager) configDirs(scope Scope) []ConfigDir { if scope == User { account := "the operator account's own configuration" dirs := []ConfigDir{} if m.Home != "" { dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".config", "systemd", "user.control"), account + " (set by a program through the manager)", true}) } dirs = append(dirs, ConfigDir{fmt.Sprintf("/run/user/%d/systemd/user", m.UID), "made at runtime, gone at the next login", false}) if m.Home != "" { dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".config", "systemd", "user"), account, true}) } dirs = append(dirs, ConfigDir{"/etc/systemd/user", "the machine's configuration, for every account", true}, ConfigDir{"/etc/xdg/systemd/user", "the machine's configuration, for every account", true}) if m.Home != "" { dirs = append(dirs, ConfigDir{filepath.Join(m.Home, ".local", "share", "systemd", "user"), "a program installed in the account's own home", false}) } return append(dirs, ConfigDir{"/usr/local/lib/systemd/user", "a program installed by hand, for every account", false}, ConfigDir{"/usr/lib/systemd/user", "a package's", false}) } return []ConfigDir{ {"/etc/systemd/system.control", "the machine's configuration (set by a program through the manager)", true}, {"/run/systemd/transient", "made at runtime, gone at the next boot", false}, {"/run/systemd/generator.early", "a generator's, made again at every boot and reload", false}, {"/etc/systemd/system", "the machine's configuration", true}, {"/run/systemd/system", "made at runtime, gone at the next boot", false}, {"/run/systemd/generator", "a generator's, made again at every boot and reload", false}, {"/usr/local/lib/systemd/system", "a program installed by hand", false}, {"/usr/lib/systemd/system", "a package's", false}, {"/run/systemd/generator.late", "a generator's, made again at every boot and reload", false}, } } // within is whether a path lies in one of the directories. func within(path string, dirs []ConfigDir) bool { path = filepath.Clean(path) for _, d := range dirs { if path == d.Path || strings.HasPrefix(path, d.Path+string(filepath.Separator)) { return true } } return false } // links finds every enable link naming the unit under the scope's configuration directories: by its own // name, or by the name of the file it points at. A directory that cannot be read is said, never skipped as // empty; one that does not exist is the ordinary case and is not. **A dependency directory that is itself a // link is followed only when it leads into the searched directories**: elsewhere it is said, and not read, // so a link cannot point the search, or a removal after it, at any directory on the machine. func (m *Manager) links(scope Scope, unit string) ([]EnableLink, []string, []string) { found, unread, same := []EnableLink{}, []string{}, []string{} dirs := m.configDirs(scope) seen := map[string]string{} for _, dir := range dirs { // One directory reached by two names is read once, under the first: the same link is not listed // twice, nor removed through the second name. if m.Resolve != nil { if real, err := m.Resolve(dir.Path); err == nil { if first, twice := seen[real]; twice { same = append(same, dir.Path+" is "+first+", read under that name") continue } seen[real] = dir.Path } } entries, err := m.ReadDir(dir.Path) if err != nil { if !errors.Is(err, fs.ErrNotExist) { unread = append(unread, dir.Path+": "+err.Error()) } continue } for _, e := range entries { dependency, of := "", "" for suffix, dep := range dependencyDirs { if strings.HasSuffix(e.Name, suffix) { dependency, of = dep, strings.TrimSuffix(e.Name, suffix) } } if dependency == "" || (!e.Dir && e.Link == "") { continue } sub := filepath.Join(dir.Path, e.Name) read := sub if e.Link != "" { target := e.Link if !filepath.IsAbs(target) { target = filepath.Join(dir.Path, target) } if !within(target, dirs) { unread = append(unread, sub+": a link to "+e.Link+", outside the directories searched, not followed") continue } read = filepath.Clean(target) } inside, err := m.ReadDir(read) if err != nil { if !errors.Is(err, fs.ErrNotExist) { unread = append(unread, sub+": "+err.Error()) } continue } for _, l := range inside { if l.Name != unit && (l.Link == "" || filepath.Base(l.Link) != unit) { continue } found = append(found, EnableLink{Path: filepath.Join(sub, l.Name), Of: of, Dependency: dependency, Target: l.Link, Dangles: l.Link != "" && l.Dangles, Error: l.Error, Link: l.Link != "", Whose: dir.Whose, removable: dir.Removable && e.Link == ""}) } } } sort.Slice(found, func(i, j int) bool { return found[i].Path < found[j].Path }) return found, unread, same } // WantedBy is what wants, requires or triggers one unit: the manager's reverse dependencies and the enable // links naming it, with a finding in words. A manager that does not answer is said beside the links, which // are read from disk and do not need it. func (m *Manager) WantedBy(scope Scope, unit string) (map[string]any, error) { if err := unitArg(unit); err != nil { return nil, err } answer := map[string]any{"unit": unit, "scope": string(scope)} manager := map[string]any{} args := []string{"show", "--no-pager"} for _, p := range append(append([]string{}, stateProperties...), reverseProperties...) { args = append(args, "--property="+p) } loadState := "" dependents := 0 if out, err := m.call(scope, "systemctl", append(args, "--", unit)...); err != nil { manager["error"] = err.Error() } else { for _, line := range strings.Split(out, "\n") { k, v, ok := strings.Cut(line, "=") if !ok || k == "" { continue } if isReverse(k) { if names := strings.Fields(v); len(names) > 0 { manager[k] = names dependents += len(names) } continue } manager[k] = v } loadState, _ = manager["LoadState"].(string) } // Through other units: list-dependencies --reverse follows the same edges from the units that pull in // those that pull this one in. Its first line is the unit itself. if out, err := m.call(scope, "systemctl", "list-dependencies", "--reverse", "--plain", "--no-legend", "--no-pager", "--", unit); err != nil { manager["reverse_error"] = err.Error() } else { chain := []string{} for i, line := range strings.Split(out, "\n") { name := strings.TrimSpace(strings.TrimLeft(line, " │├└─●○×*")) if i == 0 || name == "" || name == unit { continue } chain = append(chain, name) } if len(chain) > 0 { manager["reverse"] = chain } } answer["manager"] = manager links, unread, same := m.links(scope, unit) answer["links"] = links if len(same) > 0 { answer["same_directory"] = same } answer["searched"] = m.configDirs(scope) if len(unread) > 0 { answer["unread"] = unread } answer["findings"] = findings(scope, unit, loadState, dependents, links, unread, manager["error"] != nil, m.Home == "") return answer, nil } func isReverse(property string) bool { for _, p := range reverseProperties { if p == property { return true } } return false } // findings say what the answer means, so a caller does not have to put the parts together. func findings(scope Scope, unit, loadState string, dependents int, links []EnableLink, unread []string, managerFailed, noHome bool) []string { out := []string{} dangling, removable, unknown := 0, 0, 0 for _, l := range links { switch { case l.Dangles: dangling++ if l.removable { removable++ } out = append(out, l.Path+" makes "+l.Of+" "+strings.TrimSuffix(strings.ToLower(l.Dependency), "s")+" "+unit+" and points at "+l.Target+", which does not exist ("+l.Whose+")") case l.Error != "": unknown++ out = append(out, l.Path+" points at "+l.Target+", which could not be looked at: "+l.Error) } } if loadState == "not-found" && len(links) > 0 && dangling == 0 && unknown == 0 { out = append(out, "the unit's file is not found, and "+plural(len(links), "enable link")+" still name it") } if removable > 0 { out = append(out, "`disable` does not remove a link whose unit file is gone; `unlink-dangling` in this scope removes "+ "the dangling links named for the unit in the account's or the machine's own configuration, keeping a record of each; "+ "then `reset-failed` clears the unit's failed record") } else if dangling > 0 { out = append(out, "the dangling links are in a package's, a generator's or the runtime's directory, which puts them back: they are not the person's to remove") } incomplete := managerFailed || len(unread) > 0 || unknown > 0 || (scope == User && noHome) if len(links) == 0 && dependents == 0 { where := "in the directories searched" if incomplete { where += ", which are not all of them (below)" } if loadState == "not-found" { out = append(out, "nothing names "+unit+" "+where+": a failed record of it is stale, and `reset-failed` clears it") } else if !managerFailed { out = append(out, "no unit and no enable link pulls "+unit+" in "+where+": it starts only when asked by name, by a timer, socket or path unit the manager does not list, or by a program") } } if managerFailed { out = append(out, "the manager did not answer, so only the links on disk are known") } if scope == User && noHome { out = append(out, "the account's home is not known to this process, so its own directories were not searched") } if len(unread) > 0 { out = append(out, "some directories could not be read, so a link may be missing from this answer") } return out } func plural(n int, what string) string { if n == 1 { return "1 " + what } return strconv.Itoa(n) + " " + what + "s" } // readDir lists a directory as enable links need it, by lstat: each name, whether it is itself a directory, // where a link points, and whether what it points at does not exist — any other failure to look is said as // the error it was, never as dangling. func readDir(dir string) ([]Entry, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } out := make([]Entry, 0, len(entries)) for _, e := range entries { out = append(out, entryOf(filepath.Join(dir, e.Name()), e.Name(), e.Type())) } return out, nil } // lstatEntry is one path as readDir would list it, read again just before it is acted on. func lstatEntry(path string) (Entry, error) { info, err := os.Lstat(path) if err != nil { return Entry{}, err } return entryOf(path, filepath.Base(path), info.Mode().Type()), nil } func entryOf(full, name string, mode fs.FileMode) Entry { entry := Entry{Name: name, Dir: mode.IsDir()} if mode&fs.ModeSymlink == 0 { return entry } target, err := os.Readlink(full) if err != nil { target, entry.Error = "(unreadable)", err.Error() } entry.Link = target if _, err := os.Stat(full); errors.Is(err, fs.ErrNotExist) { entry.Dangles = true } else if err != nil { entry.Error = err.Error() } return entry }