Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ require (
github.com/rogpeppe/go-internal v1.6.1 // indirect
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
golang.org/x/term v0.20.0 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userd.go imports gopkg.in/ini.v1, so this dependency is direct. In go.mod it’s currently listed in the indirect require block with // indirect, which will cause go mod tidy churn. Move it to the main require block (or drop the // indirect marker) so module metadata matches actual usage.

Copilot uses AI. Check for mistakes.
maze.io/x/crypto v0.0.0-20190131090603-9b94c9afe066 // indirect
)
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ github.com/canonical/tcglog-parser v0.0.0-20240924110432-d15eaf652981/go.mod h1:
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.2.2 h1:xfmOhhoH5fGPgbEAlhLpJH9p0z/0Qizio9osmvn9IUY=
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
Expand Down Expand Up @@ -49,6 +51,7 @@ github.com/mvo5/libseccomp-golang v0.9.1-0.20180308152521-f4de83b52afb/go.mod h1
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM=
Expand All @@ -63,7 +66,15 @@ github.com/snapcore/maze.io-x-crypto v0.0.0-20190131090603-9b94c9afe066 h1:InG0E
github.com/snapcore/maze.io-x-crypto v0.0.0-20190131090603-9b94c9afe066/go.mod h1:VuAdaITF1MrGzxPU+8GxagM1HW2vg7QhEFEeGHbmEMU=
github.com/snapcore/secboot v0.0.0-20260129175210-e638825ef829 h1:9qeADnUPs/YhO0tty+j2zxi9dUI2Bn96y9Nc9XOKTOk=
github.com/snapcore/secboot v0.0.0-20260129175210-e638825ef829/go.mod h1:BeEYaTJC4cqXVgpjjxajO31p2kVDvXwXJJx3YD7nCaE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down Expand Up @@ -97,6 +108,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/macaroon.v1 v1.0.0 h1:BmexIS8QyY02i0uoeXwrtlH8vCS/Rmxq9uzOy4qQvk8=
gopkg.in/macaroon.v1 v1.0.0/go.mod h1:KeG3in9Jb7Z3RNA/PFngm+mISBo0Q0O9KQeF958zuoQ=
gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs=
Expand All @@ -105,5 +118,6 @@ gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
95 changes: 95 additions & 0 deletions usersession/userd/userd.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ package userd

import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"

"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
"gopkg.in/ini.v1"
"gopkg.in/tomb.v2"

"github.com/snapcore/snapd/dbusutil"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/systemd"
)

type dbusInterface interface {
Expand All @@ -50,6 +57,92 @@ var userdBusNames = []string{
"io.snapcraft.Settings",
}

func clearBrokenLink(targetPath string) error {
_, err := filepath.EvalSymlinks(targetPath)
if err != nil {
switch err.(type) {
case *fs.PathError:
logger.Noticef("deleting broken link in snap service %s", targetPath)
// it's a broken link; delete it
os.Remove(targetPath)
default:
Comment on lines +60 to +68
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearBrokenLink ignores the return value of os.Remove(targetPath), so a failure to delete the broken symlink (e.g. permissions, races) will be silently swallowed and subsequent code will proceed as if cleanup succeeded. Capture and return/log the os.Remove error, and consider only treating os.IsNotExist/broken-symlink cases as removable instead of any *fs.PathError.

Copilot uses AI. Check for mistakes.
return err
}
}
return nil
}

func reenableUserService(serviceName string) error {
logger.Noticef("re-enabling user service %s", serviceName)
sysd := systemd.New(systemd.UserMode, nil)
return sysd.DaemonReEnable([]string{serviceName})
}
Comment on lines +75 to +79
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

systemd.Systemd does not define DaemonReEnable anywhere in this repo, so sysd.DaemonReEnable(...) will not compile. Either add a corresponding method to the systemd package (wrapping systemctl --user reenable ...) or invoke systemctl --user reenable via an existing systemd API (e.g. adding a small helper in systemd and calling that here).

Copilot uses AI. Check for mistakes.

func checkServicePlacement(targetName, snapTarget string) error {
snapTargetData, err := os.ReadFile(snapTarget)
if err != nil {
return err
}
targetIni, err := ini.Load(snapTargetData)
if err != nil {
return err
}
wantedBy := ""
if section := targetIni.Section("Install"); section != nil {
wantedBy = section.Key("WantedBy").String()
}
if wantedBy != targetName {
// the symlink is in the wrong folder. Re-enable the service
// to change it.
err := reenableUserService(path.Base(snapTarget))
if err != nil {
return err
}
}
Comment on lines +90 to +101
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WantedBy in a unit file can contain multiple targets and often includes trailing whitespace/newlines. Comparing section.Key("WantedBy").String() directly against targetName can trigger unnecessary reenable calls. Consider trimming whitespace and checking whether targetName is one of the WantedBy entries (e.g. split with strings.Fields or read repeated keys) before deciding the symlink is misplaced.

Copilot uses AI. Check for mistakes.
return nil
}

func sanitizeUserServices() error {
// ensure that the user services enabled in the $HOME folder are
// correctly placed in the right .target.wants folder. This placement
// will be wrong if a service is moved from default.target to
// graphical-session.target or vice-versa, so this clean up is required.
//
// The change can happen in two cases:
//
// * when migrating from an old version of snapd without "graphical-session.target"
// support, to a new version that supports it: all the user daemons' WantedBy entry
// will be updated, and so these entries will have to be updated too.
// * when a snap adds or removes the `desktop` plug in a daemon

userSystemdQuery := path.Join(os.Getenv("HOME"), ".config", "systemd", "user", "*.target.wants")
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeUserServices builds its glob using os.Getenv("HOME") without checking for empty/undefined HOME. If HOME is empty, the glob becomes relative (e.g. .config/systemd/user/*.target.wants) and userd may scan/remove links under the current working directory. Use os.UserHomeDir() (or validate HOME is non-empty) and return early when no home dir is available.

Suggested change
userSystemdQuery := path.Join(os.Getenv("HOME"), ".config", "systemd", "user", "*.target.wants")
homeDir, err := os.UserHomeDir()
if err != nil || homeDir == "" {
logger.Noticef("cannot determine user home directory, skipping user service sanitization: %v", err)
return nil
}
userSystemdQuery := path.Join(homeDir, ".config", "systemd", "user", "*.target.wants")

Copilot uses AI. Check for mistakes.
entries, err := filepath.Glob(userSystemdQuery)
if err != nil {
return err
}
for _, targetWantPath := range entries {
snapTargetPaths, err := filepath.Glob(path.Join(targetWantPath, "snap.*"))
if err != nil {
logger.Noticef("cannot get the user service files at %s: %s", targetWantPath, err.Error())
continue
}
for _, snapTarget := range snapTargetPaths {
// Remove any broken link (that's a service that was removed)
if err = clearBrokenLink(snapTarget); err != nil {
logger.Noticef("cannot remove the broken link %s: %s", snapTarget, err.Error())
continue
}
// Check that any snap service is in the right .target.wants
targetName := strings.TrimSuffix(targetWantPath, ".wants")
if err := checkServicePlacement(path.Base(targetName), snapTarget); err != nil {
logger.Noticef("cannot process user service at %s for %s: %s", snapTarget, targetName, err.Error())
continue
}
}
}
return nil
}
Comment on lines +105 to +144
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New startup behavior (sanitizeUserServices, checkServicePlacement, and the systemctl reenable path) isn’t covered by unit tests, but this package already has a substantial test suite. Adding tests for at least: (1) broken symlink cleanup, and (2) wrong .target.wants placement triggering a single systemctl --user reenable call (using systemd.MockSystemctl) would help prevent regressions.

Copilot uses AI. Check for mistakes.

func (ud *Userd) Init() error {
var err error

Expand Down Expand Up @@ -86,6 +179,8 @@ func (ud *Userd) Init() error {
return fmt.Errorf("cannot obtain bus name '%s'", name)
}
}

sanitizeUserServices()
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeUserServices() returns an error but Init() ignores it. If the intent is best-effort cleanup, at least log the returned error so failures are diagnosable; otherwise, propagate the error to avoid silently leaving incorrect symlinks behind.

Suggested change
sanitizeUserServices()
if err := sanitizeUserServices(); err != nil {
logger.Noticef("cannot sanitize user services: %v", err)
}

Copilot uses AI. Check for mistakes.
return nil
}

Expand Down
Loading