diff --git a/core/internal/desktop/mimeapps.go b/core/internal/desktop/mimeapps.go index d208821c3..5807d5b6c 100644 --- a/core/internal/desktop/mimeapps.go +++ b/core/internal/desktop/mimeapps.go @@ -133,6 +133,18 @@ func mergedAssociations() *MimeAssociations { return merged } +// isSafeIniField rejects anything that could break out of a single +// "key=value" line in the generated INI-format mimeapps.list: a newline +// could inject a fake [Section] header or extra key=value lines, and a +// bracket could be confused for one even without a newline. mimeType and +// desktopId ultimately come from the local IPC socket with no other +// sanitization (StripMimeParams only trims a trailing ";params" suffix), +// and this file is a shared freedesktop config consumed by other +// applications (GTK, KDE, xdg-open) beyond this daemon. +func isSafeIniField(s string) bool { + return !strings.ContainsAny(s, "\n\r[]") +} + func writeUserMimeapps(update func(*MimeAssociations)) error { mimeappsWriteMu.Lock() defer mimeappsWriteMu.Unlock() @@ -152,6 +164,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error { var buf bytes.Buffer w := bufio.NewWriter(&buf) + var writeErr error writeSection := func(name string, entries map[string]string) { fmt.Fprintf(w, "[%s]\n", name) keys := make([]string, 0, len(entries)) @@ -160,7 +173,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error { } sort.Strings(keys) for _, k := range keys { - fmt.Fprintf(w, "%s=%s\n", k, entries[k]) + v := entries[k] + if !isSafeIniField(k) || !isSafeIniField(v) { + if writeErr == nil { + writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v) + } + continue + } + fmt.Fprintf(w, "%s=%s\n", k, v) } fmt.Fprintln(w) } @@ -177,6 +197,10 @@ func writeUserMimeapps(update func(*MimeAssociations)) error { writeSection(groupAdded, flatten(assoc.Added)) writeSection(groupRemoved, flatten(assoc.Removed)) + if writeErr != nil { + return writeErr + } + if err := w.Flush(); err != nil { return err } diff --git a/core/internal/geolocation/client_geoclue.go b/core/internal/geolocation/client_geoclue.go index a06aadf4d..33d3dbfe5 100644 --- a/core/internal/geolocation/client_geoclue.go +++ b/core/internal/geolocation/client_geoclue.go @@ -132,7 +132,13 @@ func (c *GeoClueClient) startSignalPump() error { if err := c.dbusConn.AddMatchSignal( dbus.WithMatchObjectPath(c.clientPath), dbus.WithMatchInterface(dbusGeoClueClientInterface), - dbus.WithMatchSender(dbusGeoClueClientLocationUpdated), + // The member name ("LocationUpdated"), not WithMatchSender: that + // expects a bus name (e.g. "org.freedesktop.GeoClue2"), and + // dbusGeoClueClientLocationUpdated is actually the full + // interface.member string -- no real sender ever matches that, so + // this match rule never matched anything and live location + // updates silently never arrived after the initial value. + dbus.WithMatchMember("LocationUpdated"), ); err != nil { return err } diff --git a/core/internal/greeter/session_launcher.go b/core/internal/greeter/session_launcher.go index daa5cd629..27a86b8c1 100644 --- a/core/internal/greeter/session_launcher.go +++ b/core/internal/greeter/session_launcher.go @@ -3,6 +3,7 @@ package greeter import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "syscall" @@ -93,18 +94,105 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) { return "", fmt.Errorf("session desktop file %q was not found", id) } +// tokenizeExecLine splits a Desktop Entry Exec= value into argv, honoring +// basic single/double quoting and backslash escapes, but never invokes a +// shell. Session .desktop files can live in a user-writable directory +// (~/.local/share/wayland-sessions), so shell metacharacters in Exec= +// must never reach an actual shell -- they're just literal argv text here. +func tokenizeExecLine(execLine string) ([]string, error) { + var tokens []string + var cur strings.Builder + hasCur := false + runes := []rune(execLine) + i := 0 + for i < len(runes) { + c := runes[i] + switch { + case c == ' ' || c == '\t': + if hasCur { + tokens = append(tokens, cur.String()) + cur.Reset() + hasCur = false + } + i++ + case c == '\'': + hasCur = true + i++ + for i < len(runes) && runes[i] != '\'' { + cur.WriteRune(runes[i]) + i++ + } + if i >= len(runes) { + return nil, fmt.Errorf("unterminated single quote") + } + i++ + case c == '"': + hasCur = true + i++ + for i < len(runes) && runes[i] != '"' { + if runes[i] == '\\' && i+1 < len(runes) { + if next := runes[i+1]; next == '"' || next == '\\' || next == '$' || next == '`' { + cur.WriteRune(next) + i += 2 + continue + } + } + cur.WriteRune(runes[i]) + i++ + } + if i >= len(runes) { + return nil, fmt.Errorf("unterminated double quote") + } + i++ + case c == '\\' && i+1 < len(runes): + hasCur = true + cur.WriteRune(runes[i+1]) + i += 2 + default: + hasCur = true + cur.WriteRune(c) + i++ + } + } + if hasCur { + tokens = append(tokens, cur.String()) + } + return tokens, nil +} + func LaunchSessionByID(sessionID string) error { execLine, err := ResolveSessionExec(sessionID) if err != nil { return err } - execLine = strings.TrimSpace(stripDesktopExecCodes(execLine)) + execLine = strings.TrimSpace(execLine) if execLine == "" { return fmt.Errorf("session %q has an empty Exec command", sessionID) } + tokens, err := tokenizeExecLine(execLine) + if err != nil { + return fmt.Errorf("session %q has an invalid Exec command: %w", sessionID, err) + } + + argv := make([]string, 0, len(tokens)) + for _, tok := range tokens { + if strings.HasPrefix(tok, "%") { + continue // field codes (%f/%U/etc.) -- sessions take no file args + } + argv = append(argv, tok) + } + if len(argv) == 0 { + return fmt.Errorf("session %q has an empty Exec command", sessionID) + } + + resolved, err := exec.LookPath(argv[0]) + if err != nil { + return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err) + } + env := append(os.Environ(), "XDG_SESSION_TYPE=wayland") - return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env) + return syscall.Exec(resolved, argv, env) } func LaunchSessionFromMemory(cacheDir, homeDir string) error { diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 1108a2b87..6189cc3a0 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -299,6 +299,14 @@ type hyprlandOverrideBind struct { Options map[string]any // Unbind: negative override (hl.unbind only, no rebind). Unbind bool + // RawLuaAction is true when Action was parsed from an existing Lua + // bind override as a hand-written custom hl.* expression that this + // code doesn't have a specific translation for (see + // luaExprToInternalAction). Only in that case is Action re-emitted + // verbatim as Lua source; every other bind (including ones parsed from + // a classic hyprland.conf bind= line, where Action is freeform + // dispatcher+params text, never Lua) always gets safely quoted. + RawLuaAction bool } func (h *HyprlandProvider) ensureWritableConfig() error { @@ -1046,18 +1054,45 @@ func luaActionStringFromHyprlangAction(action string) string { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { return expr } - return action + // Unrecognized dispatcher (typo, third-party dispatcher with no native + // translation, or -- since Action here is classic freeform + // "dispatcher params" text, never Lua -- a crafted bind line): forward + // it to hyprctl rather than dropping it, but as a properly quoted + // string argument, never as raw unescaped Lua source. Without this, an + // unrecognized dispatcher name/params containing e.g. a stray `"` could + // close the Lua string literal in writeLuaBindLine's hl.bind(...) call + // and inject arbitrary Lua that Hyprland executes on reload. + // + // This function is never used for RawLuaAction binds (see + // writeLuaBindLine): those already are a Lua expression and must be + // emitted verbatim instead, which is exactly the "custom Lua + // dispatcher" escape hatch this deliberately doesn't reach. + return luaHyprctlDispatchFunction(action) } -func luaExprToInternalAction(expr string) string { +// luaExprToInternalAction converts a parsed Lua bind expression back into +// the classic "dispatcher params" text form used elsewhere in this file. It +// also reports whether expr didn't match any known hl.dsp.*/hl.dispatch/ +// hl.exec_cmd shape (luaExprToDispatcherParams's default case returns the +// expression itself unchanged as the "dispatcher") -- i.e. whether this is +// a genuine hand-written custom Lua dispatcher rather than something this +// code knows how to translate. That distinction matters when writing the +// bind back out: a custom Lua expression must be re-emitted verbatim as +// Lua, but anything else (including this same fallback text if it had come +// from a classic hyprland.conf bind= line instead) must always be safely +// quoted -- see writeLuaBindLine. +func luaExprToInternalAction(expr string) (action string, isRawLua bool) { d, p := luaExprToDispatcherParams(expr) + if d == expr && p == "" { + return expr, true + } if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") { - return "exec " + p + return "exec " + p, false } if p != "" { - return d + " " + p + return d + " " + p, false } - return d + return d, false } func luaBindOptions(bind *hyprlandOverrideBind) []string { @@ -1075,20 +1110,32 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string { } func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { - key := formatLuaBindKey(bind.Key) + // strconv.Quote, not a bare %s inside a manually-written Lua string + // literal: a parsed bind key containing a stray `"` would otherwise + // close the literal early and let whatever follows execute as Lua. + key := strconv.Quote(formatLuaBindKey(bind.Key)) if bind.Unbind { - fmt.Fprintf(sb, `hl.unbind("%s")`, key) + fmt.Fprintf(sb, `hl.unbind(%s)`, key) sb.WriteByte('\n') return } - expr := luaActionStringFromHyprlangAction(bind.Action) + // RawLuaAction binds already hold an exact Lua expression (round-tripped + // from an existing custom hl.* call this code doesn't translate) and + // must be re-emitted verbatim; anything else is freeform dispatcher + // text that must always go through the safe-quoting path. + var expr string + if bind.RawLuaAction { + expr = bind.Action + } else { + expr = luaActionStringFromHyprlangAction(bind.Action) + } opts := luaBindOptions(bind) - fmt.Fprintf(sb, `hl.unbind("%s")`, key) + fmt.Fprintf(sb, `hl.unbind(%s)`, key) sb.WriteByte('\n') if len(opts) > 0 { - fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", ")) + fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", ")) } else { - fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr) + fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr) } sb.WriteByte('\n') } @@ -1104,17 +1151,18 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) { } internalKey := luaKeyComboToInternalKey(kbc) - action := luaExprToInternalAction(actionExpr) + action, isRawLua := luaExprToInternalAction(actionExpr) flags := luaBindOptFlags(optSuffix) description := luaBindOptDescription(optSuffix) if description == "" { description = luaLineTrailingComment(line) } return &hyprlandOverrideBind{ - Key: internalKey, - Action: action, - Description: description, - Flags: flags, + Key: internalKey, + Action: action, + Description: description, + Flags: flags, + RawLuaAction: isRawLua, }, true } diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index afaff44ea..b2a5191a9 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -147,9 +147,10 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) { var sb strings.Builder writeLuaBindLine(&sb, &hyprlandOverrideBind{ - Key: "Super+u", - Action: "hl.dsp.no_op()", - Description: "Custom Lua", + Key: "Super+u", + Action: "hl.dsp.no_op()", + Description: "Custom Lua", + RawLuaAction: true, // set by parseLuaBindOverrideLine for genuine custom Lua round-trips }) want := `hl.unbind("SUPER + U") @@ -159,6 +160,34 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })` } } +// TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag verifies +// that a bind carrying Lua-shaped text in Action but *not* marked +// RawLuaAction (i.e. it isn't a genuine round-tripped custom dispatcher -- +// this is what a crafted/unrecognized classic hyprland.conf bind= line +// looks like by the time it reaches here) is always safely quoted rather +// than treated as literal Lua source. +func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) { + var sb strings.Builder + writeLuaBindLine(&sb, &hyprlandOverrideBind{ + Key: "Super+u", + Action: `customdispatcher "),os.execute("id")--`, + }) + + got := sb.String() + if !strings.Contains(got, "hl.exec_cmd(") { + t.Fatalf("expected unrecognized action to go through the safe hyprctl-dispatch wrapper, got %q", got) + } + // Every bare (non-backslash-escaped) `"` in the output should be a + // clean open/close pair for some string literal (the key, or the + // hyprctl-dispatch argument). If the payload's `")` had actually broken + // out of its string, it would introduce an extra, unpaired bare quote, + // making this count odd. + withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "") + if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 { + t.Fatalf("payload broke out of its string literal (found %d unpaired bare quotes): %q", n, got) + } +} + func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { tests := []struct { action string @@ -226,15 +255,21 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) { } } -func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) { +// TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText: +// luaActionStringFromHyprlangAction is used for Action text that is +// classic freeform "dispatcher params" (never Lua) -- it must always +// safely quote anything it doesn't recognize, precisely because that text +// could be attacker-influenced content from a hyprland.conf bind= line, +// not just an innocuous typo. The "leave custom Lua dispatcher raw" +// behavior now lives only in writeLuaBindLine, gated on the explicit +// RawLuaAction flag set by parseLuaBindOverrideLine -- see +// TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw. +func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) { got := luaActionStringFromHyprlangAction("hl.dsp.no_op()") - want := `hl.dsp.no_op()` + want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end` if got != want { t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) } - if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") { - t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got) - } } func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) { diff --git a/core/internal/plugins/manager.go b/core/internal/plugins/manager.go index 8ded155cd..e83d7134c 100644 --- a/core/internal/plugins/manager.go +++ b/core/internal/plugins/manager.go @@ -64,7 +64,26 @@ func (m *Manager) findInstalledPath(pluginID string) (string, error) { return m.findInDir(systemDir, pluginID) } +// isSafePluginPathComponent rejects anything that isn't a single, plain +// path component: no separators, no "." or "..", not empty. Plugin IDs and +// names are supposed to be plain identifiers, matched directly against a +// directory name one level under a trusted plugins dir -- allowing a +// caller-supplied value containing "/" or ".." here would let +// filepath.Join resolve outside that directory entirely (e.g. an uninstall +// request for "../../../etc" resolving to a real, existing directory that +// then gets RemoveAll'd). +func isSafePluginPathComponent(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + return !strings.ContainsAny(s, "/\\") +} + func (m *Manager) findInDir(dir, pluginID string) (string, error) { + if !isSafePluginPathComponent(pluginID) { + return "", fmt.Errorf("invalid plugin id: %q", pluginID) + } + // First, check if folder with exact ID name exists exactPath := filepath.Join(dir, pluginID) if exists, _ := afero.DirExists(m.fs, exactPath); exists { @@ -507,6 +526,10 @@ func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) { } func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) { + if !isSafePluginPathComponent(idOrName) { + return "", fmt.Errorf("invalid plugin id/name: %q", idOrName) + } + // Check exact folder name match first exactPath := filepath.Join(dir, idOrName) if exists, _ := afero.DirExists(m.fs, exactPath); exists { diff --git a/core/internal/privesc/privesc.go b/core/internal/privesc/privesc.go index 2c4630022..9f5ddbb11 100644 --- a/core/internal/privesc/privesc.go +++ b/core/internal/privesc/privesc.go @@ -135,15 +135,18 @@ func EscapeSingleQuotes(s string) string { } // MakeCommand returns a bash command string that runs `command` with the -// detected tool. When the tool supports stdin passwords and password is -// non-empty, the password is piped in. Otherwise the tool is invoked with -// no non-interactive flag so that an interactive TTY prompt is still -// possible for CLI callers. +// detected tool, for the tools/paths that don't take a password (doas, +// run0, or sudo with no password supplied -- those prompt interactively on +// a TTY instead). The sudo-with-password case is handled by ExecCommand +// directly via stdin, never by building a command string, since a password +// embedded in a command string would end up in that process's argv (and +// therefore be readable by any local user via /proc//cmdline or `ps` +// for as long as the command runs). // // If detection fails, the returned shell string exits 1 with an error // message so callers that treat the *exec.Cmd as infallible still fail // deterministically. -func MakeCommand(password, command string) string { +func MakeCommand(command string) string { t, err := Detect() if err != nil { return failingShell(err) @@ -151,9 +154,6 @@ func MakeCommand(password, command string) string { switch t { case ToolSudo: - if password != "" { - return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command) - } return fmt.Sprintf("sudo %s", command) case ToolDoas: return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command)) @@ -167,8 +167,21 @@ func MakeCommand(password, command string) string { // ExecCommand builds an exec.Cmd that runs `command` as root via the // detected tool. Detection errors surface at Run() time as a failing // command writing a clear error to stderr. +// +// When the tool is sudo and password is non-empty, the password is wired up +// via the child's stdin (sudo -S) rather than being embedded in the command +// line, so it never appears in argv/`ps`/`/proc//cmdline`. func ExecCommand(ctx context.Context, password, command string) *exec.Cmd { - return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command)) + t, err := Detect() + if err != nil { + return exec.CommandContext(ctx, "bash", "-c", failingShell(err)) + } + if t == ToolSudo && password != "" { + cmd := exec.CommandContext(ctx, "sudo", "-S", "sh", "-c", command) + cmd.Stdin = strings.NewReader(password + "\n") + return cmd + } + return exec.CommandContext(ctx, "bash", "-c", MakeCommand(command)) } // ExecArgv builds an exec.Cmd that runs argv as root via the detected tool. diff --git a/core/internal/screenshot/screenshot.go b/core/internal/screenshot/screenshot.go index 71b7fe591..3b4def371 100644 --- a/core/internal/screenshot/screenshot.go +++ b/core/internal/screenshot/screenshot.go @@ -747,12 +747,17 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, bpp := format.BytesPerPixel() if int(e.Stride) < int(e.Width)*bpp { log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp) + // Without this, we never call frame.Copy() and the compositor + // has no reason to ever send ready/failed -- the dispatch loop + // below waits forever on an event neither side is going to send. + failed = true return } var err error buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride)) if err != nil { log.Error("failed to create buffer", "err", err) + failed = true return } buf.Format = format @@ -771,6 +776,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size())) if err != nil { log.Error("failed to create pool", "err", err) + failed = true return } @@ -779,6 +785,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, pool.Destroy() pool = nil log.Error("failed to create wl_buffer", "err", err) + failed = true return } diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 6486ecda5..8dd317dfd 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -1839,21 +1839,39 @@ func (m *Manager) EntryToFile(entry *Entry) string { return "" } +// dbusConnForFlatpak returns the lazily-created shared session bus +// connection, creating it under dbusConnMutex if this is the first call. +// Guarding the whole check-then-create means two concurrent +// ExportFileForFlatpak calls (each on its own goroutine, one per inbound +// IPC request) can't both dial a connection and race to assign the field. +func (m *Manager) dbusConnForFlatpak() (*dbus.Conn, error) { + m.dbusConnMutex.Lock() + defer m.dbusConnMutex.Unlock() + + if m.dbusConn != nil { + return m.dbusConn, nil + } + + conn, err := dbus.ConnectSessionBus() + if err != nil { + return nil, fmt.Errorf("connect session bus: %w", err) + } + if !conn.SupportsUnixFDs() { + conn.Close() + return nil, fmt.Errorf("D-Bus connection does not support Unix FD passing") + } + m.dbusConn = conn + return conn, nil +} + func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) { if _, err := os.Stat(filePath); err != nil { return "", fmt.Errorf("file not found: %w", err) } - if m.dbusConn == nil { - conn, err := dbus.ConnectSessionBus() - if err != nil { - return "", fmt.Errorf("connect session bus: %w", err) - } - if !conn.SupportsUnixFDs() { - conn.Close() - return "", fmt.Errorf("D-Bus connection does not support Unix FD passing") - } - m.dbusConn = conn + dbusConn, err := m.dbusConnForFlatpak() + if err != nil { + return "", err } file, err := os.Open(filePath) @@ -1862,7 +1880,7 @@ func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) { } fd := int(file.Fd()) - portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents") + portal := dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents") var docIds []string var extra map[string]dbus.Variant diff --git a/core/internal/server/clipboard/types.go b/core/internal/server/clipboard/types.go index 2427badec..8b371b9d5 100644 --- a/core/internal/server/clipboard/types.go +++ b/core/internal/server/clipboard/types.go @@ -153,7 +153,14 @@ type Manager struct { notifierWg sync.WaitGroup lastState *State - dbusConn *dbus.Conn + // dbusConn is lazily created by ExportFileForFlatpak; dbusConnMutex + // guards the check-then-create there, since each inbound IPC request + // runs on its own goroutine and two concurrent clipboard.copyFile + // calls could otherwise both see it nil, both dial a fresh connection, + // and race to assign the field (data race on the pointer itself, plus + // leaking whichever connection lost the race). + dbusConn *dbus.Conn + dbusConnMutex sync.Mutex } func (m *Manager) GetState() State { diff --git a/core/internal/server/cups/subscription.go b/core/internal/server/cups/subscription.go index 6b369b4ea..16d4c5e92 100644 --- a/core/internal/server/cups/subscription.go +++ b/core/internal/server/cups/subscription.go @@ -37,6 +37,18 @@ func (sm *SubscriptionManager) Start() error { return fmt.Errorf("subscription manager already running") } sm.running = true + // Fresh channel here, not in Stop(): if Manager.eventHandler() is busy + // draining a backlog right when Stop() runs, it might not re-enter its + // select until after Stop() had already swapped in a replacement -- + // and would then block forever on that new, empty channel that nothing + // will ever write to or close, instead of observing the close it + // missed. Only ever replacing the channel here, immediately before the + // one writer goroutine (notificationLoop) starts, means Stop()'s + // close() is the last thing that ever happens to whatever channel is + // current between one Start()/Stop() cycle and the next -- so + // eventHandler is guaranteed to observe it, no matter how far behind + // it's running. + sm.eventChan = make(chan SubscriptionEvent, 100) sm.mu.Unlock() subID, err := sm.createSubscription() @@ -206,6 +218,8 @@ func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEven } func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent { + sm.mu.Lock() + defer sm.mu.Unlock() return sm.eventChan } @@ -228,6 +242,20 @@ func (sm *SubscriptionManager) Stop() { } sm.stopChan = make(chan struct{}) + + // notificationLoop (the only writer, just joined via sm.wg.Wait() above) + // has exited, so it's safe to close this now. Without it, Manager's + // eventHandler() -- which exits only on <-m.stopChan (full manager + // shutdown) or this channel closing -- blocks forever on every + // Unsubscribe() of the last subscriber, and Unsubscribe()'s + // m.eventWG.Wait() deadlocks that caller's goroutine permanently. + // + // Deliberately NOT recreated here (unlike stopChan above): Start() + // allocates the replacement, right before the next notificationLoop + // starts. See the comment there for why that matters. + sm.mu.Lock() + close(sm.eventChan) + sm.mu.Unlock() } func (sm *SubscriptionManager) cancelSubscription() { diff --git a/core/internal/server/cups/subscription_dbus.go b/core/internal/server/cups/subscription_dbus.go index b205a6baf..057d5490e 100644 --- a/core/internal/server/cups/subscription_dbus.go +++ b/core/internal/server/cups/subscription_dbus.go @@ -38,6 +38,11 @@ func (sm *DBusSubscriptionManager) Start() error { return fmt.Errorf("subscription manager already running") } sm.running = true + // Fresh channel here, not in Stop(): see the identical comment in + // subscription.go's SubscriptionManager.Start() for why -- Stop() + // must never replace this while a not-yet-caught-up eventHandler() + // could end up observing the replacement instead of the close. + sm.eventChan = make(chan SubscriptionEvent, 100) sm.mu.Unlock() conn, err := dbus.ConnectSystemBus() @@ -252,6 +257,8 @@ func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) Subscriptio } func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent { + sm.mu.Lock() + defer sm.mu.Unlock() return sm.eventChan } @@ -278,6 +285,18 @@ func (sm *DBusSubscriptionManager) Stop() { } sm.stopChan = make(chan struct{}) + + // dbusListenerLoop (the only writer, just joined via sm.wg.Wait() above) + // has exited, so it's safe to close this now. See the identical fix and + // comment in subscription.go's SubscriptionManager.Stop() for why: this + // channel closing is Manager.eventHandler()'s only way to return short + // of a full manager shutdown, so without this Unsubscribe() of the last + // subscriber deadlocks forever on eventWG.Wait(). Deliberately NOT + // recreated here -- Start() allocates the replacement (see the comment + // there for why that matters). + sm.mu.Lock() + close(sm.eventChan) + sm.mu.Unlock() } func (sm *DBusSubscriptionManager) cancelSubscription() { diff --git a/core/internal/server/freedesktop/manager.go b/core/internal/server/freedesktop/manager.go index c1d550191..384043e51 100644 --- a/core/internal/server/freedesktop/manager.go +++ b/core/internal/server/freedesktop/manager.go @@ -137,22 +137,28 @@ func (m *Manager) consumeSelfEcho(value uint32) bool { } func (m *Manager) watchSettingsChanges() { - conn, err := dbus.ConnectSessionBus() - if err != nil { - log.Warnf("color-scheme watcher: session bus connect: %v", err) + // Reuse the shared session connection instead of opening a new one: + // a dedicated connection here was never stored on Manager, so Close() + // (which only closes m.systemConn/m.sessionConn) had no way to reach + // it, and the connection plus this goroutine leaked for the life of + // the process every time a Manager was created. + if m.sessionConn == nil { return } + conn := m.sessionConn if err := conn.AddMatchSignal( dbus.WithMatchInterface(dbusPortalSettingsInterface), dbus.WithMatchMember("SettingChanged"), ); err != nil { log.Warnf("Failed to watch portal settings changes: %v", err) - conn.Close() return } signals := make(chan *dbus.Signal, 64) + m.stateMutex.Lock() + m.settingsSignals = signals + m.stateMutex.Unlock() conn.Signal(signals) for sig := range signals { @@ -309,6 +315,18 @@ func (m *Manager) Close() { m.systemConn.Close() } if m.sessionConn != nil { + m.sessionConn.RemoveMatchSignal( + dbus.WithMatchInterface(dbusPortalSettingsInterface), + dbus.WithMatchMember("SettingChanged"), + ) + m.stateMutex.Lock() + signals := m.settingsSignals + m.settingsSignals = nil + m.stateMutex.Unlock() + if signals != nil { + m.sessionConn.RemoveSignal(signals) + close(signals) + } m.sessionConn.Close() } } diff --git a/core/internal/server/freedesktop/types.go b/core/internal/server/freedesktop/types.go index 0d9d9c5ae..3930ad716 100644 --- a/core/internal/server/freedesktop/types.go +++ b/core/internal/server/freedesktop/types.go @@ -71,4 +71,9 @@ type Manager struct { screensaverGnomeClaimed bool selfEchoMu sync.Mutex selfEchoes []colorSchemeEcho + // settingsSignals is the channel watchSettingsChanges registers on the + // shared sessionConn; guarded by stateMutex since it's set from that + // goroutine and read from Close(). nil until watchSettingsChanges has + // actually started listening. + settingsSignals chan *dbus.Signal } diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index e292bdfe7..ed056afe8 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -57,6 +57,17 @@ type NetworkManagerBackend struct { wifiDev any wifiDevices map[string]*wifiDeviceInfo + // devMutex guards ethernetDevices/wifiDevices: they're written from the + // signal-pump goroutine's device-added/removed handlers and read from + // every request-handling goroutine (one per inbound IPC request). A + // concurrent map read/write without this is a Go runtime fatal error, + // not just a panic -- unrecoverable even with a defer/recover in the + // caller. Never hold this lock while calling another method: several + // methods that touch these maps are called both from within an + // already-locked handler and directly from request handlers, and + // sync.RWMutex is not reentrant. + devMutex sync.RWMutex + dbusConn *dbus.Conn signals chan *dbus.Signal sigWG sync.WaitGroup @@ -185,12 +196,12 @@ func (b *NetworkManagerBackend) Initialize() error { } hwAddr, _ := w.GetPropertyHwAddress() - b.ethernetDevices[iface] = ðernetDeviceInfo{ + b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{ device: dev, wired: w, name: iface, hwAddress: hwAddr, - } + }) if b.ethernetDevice == nil { b.ethernetDevice = dev @@ -214,12 +225,12 @@ func (b *NetworkManagerBackend) Initialize() error { } hwAddr, _ := w.GetPropertyHwAddress() - b.wifiDevices[iface] = &wifiDeviceInfo{ + b.setWifiDeviceInfo(iface, &wifiDeviceInfo{ device: dev, wireless: w, name: iface, hwAddress: hwAddr, - } + }) if b.wifiDevice == nil { b.wifiDevice = dev @@ -267,6 +278,94 @@ func (b *NetworkManagerBackend) Initialize() error { return nil } +// ethernetDevicesSnapshot returns a shallow copy of the ethernet-devices +// map, safe to read/iterate without holding devMutex. +func (b *NetworkManagerBackend) ethernetDevicesSnapshot() map[string]*ethernetDeviceInfo { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + out := make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices)) + for k, v := range b.ethernetDevices { + out[k] = v + } + return out +} + +// wifiDevicesSnapshot returns a shallow copy of the wifi-devices map, safe +// to read/iterate without holding devMutex. +func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + out := make(map[string]*wifiDeviceInfo, len(b.wifiDevices)) + for k, v := range b.wifiDevices { + out[k] = v + } + return out +} + +func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + info, ok := b.ethernetDevices[iface] + return info, ok +} + +func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo, bool) { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + info, ok := b.wifiDevices[iface] + return info, ok +} + +func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) { + b.devMutex.Lock() + b.ethernetDevices[iface] = info + b.devMutex.Unlock() +} + +func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDeviceInfo) { + b.devMutex.Lock() + b.wifiDevices[iface] = info + b.devMutex.Unlock() +} + +// removeEthernetDeviceByPath deletes the ethernet device at path (if any) +// and returns its info plus a snapshot of what's left, so the caller can +// pick a replacement "primary" device without holding devMutex while doing +// so (picking a replacement calls back into other locking methods). +func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) { + b.devMutex.Lock() + defer b.devMutex.Unlock() + for iface, info := range b.ethernetDevices { + if info.device.GetPath() == path { + delete(b.ethernetDevices, iface) + remaining = make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices)) + for k, v := range b.ethernetDevices { + remaining[k] = v + } + return info, remaining, true + } + } + return nil, nil, false +} + +// removeWifiDeviceByPath is the wifi-device counterpart of +// removeEthernetDeviceByPath. +func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (removed *wifiDeviceInfo, remaining map[string]*wifiDeviceInfo, found bool) { + b.devMutex.Lock() + defer b.devMutex.Unlock() + for iface, info := range b.wifiDevices { + if info.device.GetPath() == path { + delete(b.wifiDevices, iface) + remaining = make(map[string]*wifiDeviceInfo, len(b.wifiDevices)) + for k, v := range b.wifiDevices { + remaining[k] = v + } + return info, remaining, true + } + } + return nil, nil, false +} + func (b *NetworkManagerBackend) Close() { close(b.stopChan) b.StopMonitoring() diff --git a/core/internal/server/network/backend_networkmanager_ethernet.go b/core/internal/server/network/backend_networkmanager_ethernet.go index 47f785b4d..dd96a5132 100644 --- a/core/internal/server/network/backend_networkmanager_ethernet.go +++ b/core/internal/server/network/backend_networkmanager_ethernet.go @@ -323,7 +323,7 @@ func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice { } func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error { - info, ok := b.ethernetDevices[device] + info, ok := b.ethernetDeviceByIface(device) if !ok { return fmt.Errorf("ethernet device %s not found", device) } @@ -345,9 +345,10 @@ func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error { } func (b *NetworkManagerBackend) updateAllEthernetDevices() { - devices := make([]EthernetDevice, 0, len(b.ethernetDevices)) + ethernetDevices := b.ethernetDevicesSnapshot() + devices := make([]EthernetDevice, 0, len(ethernetDevices)) - for name, info := range b.ethernetDevices { + for name, info := range ethernetDevices { state, _ := info.device.GetPropertyState() connected := state == gonetworkmanager.NmDeviceStateActivated driver, _ := info.device.GetPropertyDriver() diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 1ea52cba9..6fe525265 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -112,7 +112,7 @@ func (b *NetworkManagerBackend) startSignalPump() error { return err } - for _, info := range b.wifiDevices { + for _, info := range b.wifiDevicesSnapshot() { if err := conn.AddMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchInterface(dbusPropsInterface), @@ -124,7 +124,7 @@ func (b *NetworkManagerBackend) startSignalPump() error { } } - for _, info := range b.ethernetDevices { + for _, info := range b.ethernetDevicesSnapshot() { if err := conn.AddMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchInterface(dbusPropsInterface), @@ -227,7 +227,7 @@ func (b *NetworkManagerBackend) stopSignalPump() { dbus.WithMatchMember("StateChanged"), ) - for _, info := range b.wifiDevices { + for _, info := range b.wifiDevicesSnapshot() { b.dbusConn.RemoveMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchInterface(dbusPropsInterface), @@ -235,7 +235,7 @@ func (b *NetworkManagerBackend) stopSignalPump() { ) } - for _, info := range b.ethernetDevices { + for _, info := range b.ethernetDevicesSnapshot() { b.dbusConn.RemoveMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchInterface(dbusPropsInterface), @@ -550,12 +550,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { } hwAddr, _ := w.GetPropertyHwAddress() - b.ethernetDevices[iface] = ðernetDeviceInfo{ + b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{ device: dev, wired: w, name: iface, hwAddress: hwAddr, - } + }) if b.ethernetDevice == nil { b.ethernetDevice = dev @@ -573,12 +573,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { } hwAddr, _ := w.GetPropertyHwAddress() - b.wifiDevices[iface] = &wifiDeviceInfo{ + b.setWifiDeviceInfo(iface, &wifiDeviceInfo{ device: dev, wireless: w, name: iface, hwAddress: hwAddr, - } + }) if b.wifiDevice == nil { b.wifiDevice = dev @@ -603,57 +603,49 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) ) } - for iface, info := range b.ethernetDevices { - if info.device.GetPath() == devicePath { - delete(b.ethernetDevices, iface) - - if b.ethernetDevice != nil { - dev := b.ethernetDevice.(gonetworkmanager.Device) - if dev.GetPath() == devicePath { - b.ethernetDevice = nil - for _, remaining := range b.ethernetDevices { - b.ethernetDevice = remaining.device - break - } + if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found { + if b.ethernetDevice != nil { + dev := b.ethernetDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.ethernetDevice = nil + for _, r := range remaining { + b.ethernetDevice = r.device + break } } + } - b.updateAllEthernetDevices() - b.updateEthernetState() - b.listEthernetConnections() - b.updatePrimaryConnection() + b.updateAllEthernetDevices() + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() - if b.onStateChange != nil { - b.onStateChange() - } - return + if b.onStateChange != nil { + b.onStateChange() } + return } - for iface, info := range b.wifiDevices { - if info.device.GetPath() == devicePath { - delete(b.wifiDevices, iface) - - if b.wifiDevice != nil { - dev := b.wifiDevice.(gonetworkmanager.Device) - if dev.GetPath() == devicePath { - b.wifiDevice = nil - b.wifiDev = nil - for _, remaining := range b.wifiDevices { - b.wifiDevice = remaining.device - b.wifiDev = remaining.wireless - break - } + if _, remaining, found := b.removeWifiDeviceByPath(devicePath); found { + if b.wifiDevice != nil { + dev := b.wifiDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.wifiDevice = nil + b.wifiDev = nil + for _, r := range remaining { + b.wifiDevice = r.device + b.wifiDev = r.wireless + break } } + } - b.updateAllWiFiDevices() - b.updateWiFiState() + b.updateAllWiFiDevices() + b.updateWiFiState() - if b.onStateChange != nil { - b.onStateChange() - } - return + if b.onStateChange != nil { + b.onStateChange() } + return } } diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index 348fed301..c1361cc0f 100644 --- a/core/internal/server/network/backend_networkmanager_state.go +++ b/core/internal/server/network/backend_networkmanager_state.go @@ -76,7 +76,7 @@ func (b *NetworkManagerBackend) updateEthernetState() error { var connectedIP string var anyConnected bool - for name, info := range b.ethernetDevices { + for name, info := range b.ethernetDevicesSnapshot() { state, err := info.device.GetPropertyState() if err != nil { continue diff --git a/core/internal/server/network/backend_networkmanager_wifi.go b/core/internal/server/network/backend_networkmanager_wifi.go index 4f4ae8301..d32c45fdc 100644 --- a/core/internal/server/network/backend_networkmanager_wifi.go +++ b/core/internal/server/network/backend_networkmanager_wifi.go @@ -973,7 +973,7 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool } func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error { - devInfo, ok := b.wifiDevices[device] + devInfo, ok := b.wifiDeviceByIface(device) if !ok { return fmt.Errorf("WiFi device not found: %s", device) } @@ -995,7 +995,7 @@ func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error { } func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error { - devInfo, ok := b.wifiDevices[device] + devInfo, ok := b.wifiDeviceByIface(device) if !ok { return fmt.Errorf("WiFi device not found: %s", device) } @@ -1047,7 +1047,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { wifiConnected := b.state.WiFiConnected b.stateMutex.RUnlock() - for name, devInfo := range b.wifiDevices { + for name, devInfo := range b.wifiDevicesSnapshot() { state, _ := devInfo.device.GetPropertyState() connected := state == gonetworkmanager.NmDeviceStateActivated @@ -1211,7 +1211,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) { if deviceName != "" { - devInfo, ok := b.wifiDevices[deviceName] + devInfo, ok := b.wifiDeviceByIface(deviceName) if !ok { return nil, fmt.Errorf("WiFi device not found: %s", deviceName) } @@ -1224,7 +1224,7 @@ func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (* dev := b.wifiDevice.(gonetworkmanager.Device) iface, _ := dev.GetPropertyInterface() - if devInfo, ok := b.wifiDevices[iface]; ok { + if devInfo, ok := b.wifiDeviceByIface(iface); ok { return devInfo, nil } diff --git a/core/internal/server/router.go b/core/internal/server/router.go index d07485678..65d2e57d8 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -166,7 +166,14 @@ func RouteRequest(conn net.Conn, req models.Request) { models.RespondError(conn, req.ID, "dbus manager not initialized") return } - serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID) + // per-connection, not a shared constant: sharing one ID across every + // client meant two clients subscribing to the same signal fought + // over one channel, and either one disconnecting tore down every + // other client's match-rule subscriptions too (see server.go's use + // of the same clientID formula for the streaming "subscribe" + // endpoint, which must stay in sync with this one). + clientID := fmt.Sprintf("meta-client-%p", conn) + serverDbus.HandleRequest(conn, req, dbusManager, clientID) return } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 5d2480f91..8371f9a1c 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -8,6 +8,7 @@ import ( "net" "os" "path/filepath" + "runtime/debug" "strconv" "strings" "sync" @@ -81,8 +82,6 @@ var locationManager *location.Manager var sysUpdateManager *sysupdate.Manager var geoClientInstance geolocation.Client -const dbusClientID = "dms-dbus-client" - var capabilitySubscribers syncmap.Map[string, chan ServerInfo] var cupsSubscribers syncmap.Map[string, bool] var cupsSubscriberCount atomic.Int32 @@ -398,6 +397,11 @@ func InitializeSysUpdateManager() error { func handleConnection(conn net.Conn) { defer conn.Close() + defer func() { + if r := recover(); r != nil { + log.Errorf("handleConnection panic recovered: panic=%v\n%s", r, debug.Stack()) + } + }() caps := getCapabilities() capsData, _ := json.Marshal(caps) @@ -415,10 +419,25 @@ func handleConnection(conn net.Conn) { continue } - go RouteRequest(conn, req) + go routeRequestRecovered(conn, req) } } +// routeRequestRecovered runs RouteRequest with a panic boundary so that a +// bug in any one handler (bad type assertion, nil deref, out-of-range +// index, etc. -- reachable from client-controlled req.Method/req.Params) +// can't crash the whole daemon and disconnect every other client. It +// reports back to the offending client as a normal error response instead. +func routeRequestRecovered(conn net.Conn, req models.Request) { + defer func() { + if r := recover(); r != nil { + log.Errorf("RouteRequest panic recovered: method=%s panic=%v\n%s", req.Method, r, debug.Stack()) + models.RespondError(conn, req.ID, "internal server error") + } + }() + RouteRequest(conn, req) +} + func getCapabilities() Capabilities { caps := []string{"plugins"} @@ -1249,10 +1268,10 @@ func handleSubscribe(conn net.Conn, req models.Request) { if shouldSubscribe("dbus") && dbusManager != nil { wg.Add(1) - dbusChan := dbusManager.SubscribeSignals(dbusClientID) + dbusChan := dbusManager.SubscribeSignals(clientID) go func() { defer wg.Done() - defer dbusManager.UnsubscribeSignals(dbusClientID) + defer dbusManager.UnsubscribeSignals(clientID) for { select { diff --git a/core/pkg/go-wayland/wayland/client/context.go b/core/pkg/go-wayland/wayland/client/context.go index 4eee2cb64..2dcad4531 100644 --- a/core/pkg/go-wayland/wayland/client/context.go +++ b/core/pkg/go-wayland/wayland/client/context.go @@ -96,7 +96,7 @@ func (ctx *Context) GetDispatch() func() error { } } - return func() error { + return func() (dispatchErr error) { proxy, ok := ctx.objects.Load(senderID) if !ok { return nil // Proxy already deleted via delete_id, silently ignore @@ -111,6 +111,22 @@ func (ctx *Context) GetDispatch() func() error { return fmt.Errorf("%w (senderID=%d)", ErrDispatchSenderUnsupported, senderID) } + // The generated per-object Dispatch methods trust length/object-ID + // fields taken straight off the wire with no bounds checking (see + // e.g. client.go's event handlers). A malformed or truncated + // message from a misbehaving compositor -- or simply a bug in a + // rarely-exercised event decoder -- panics here. Since Dispatch is + // called from a shared loop used by every CLI tool (screenshot, + // colorpicker, clipboard) as well as the long-lived daemon, an + // unrecovered panic here would otherwise crash the whole process + // over a single bad event. Recover and surface it as a normal + // error instead. + defer func() { + if r := recover(); r != nil { + dispatchErr = fmt.Errorf("dispatch: panic handling opcode=%d senderID=%d: %v", opcode, senderID, r) + } + }() + sender.Dispatch(opcode, fd, data) return nil }