diff --git a/locale-helper/allow_caller.go b/locale-helper/allow_caller.go
new file mode 100644
index 0000000..6c0d19f
--- /dev/null
+++ b/locale-helper/allow_caller.go
@@ -0,0 +1,454 @@
+// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+ ofdbus "github.com/linuxdeepin/go-dbus-factory/system/org.freedesktop.dbus"
+ "github.com/linuxdeepin/go-lib/dbusutil"
+)
+
+const (
+ allowCallerStateFile = "/run/dde-api/locale-helper-allow-callers.json"
+ privilegedGroup = "deepin-daemon"
+ invalidGroupID = ^uint32(0)
+)
+
+type allowCallerBus interface {
+ NameHasOwner(name string) (bool, error)
+ GetConnUID(name string) (uint32, error)
+ GetConnPID(name string) (uint32, error)
+ GetBusID() (string, error)
+}
+
+type localeHelperBus struct {
+ *dbusutil.Service
+}
+
+func (s localeHelperBus) GetBusID() (string, error) {
+ var id string
+ err := s.Conn().BusObject().Call("org.freedesktop.DBus.GetId", 0).Store(&id)
+ return id, err
+}
+
+type persistedAllowCallers struct {
+ BusID string `json:"busId"`
+ Callers []string `json:"callers"`
+}
+
+// errAllowCallerNotEnabled reports that no caller has been registered through
+// SetAllowCaller yet, which means the service was not started via
+// deepin-security-loader. Callers should fall back to Polkit authorization.
+var errAllowCallerNotEnabled = errors.New("allow-caller registry is not enabled")
+
+type allowCallerRegistry struct {
+ service allowCallerBus
+ stateFile string
+ busID string
+ privilegedGroupID uint32
+ processGroups func(uint32) ([]uint32, error)
+ processParent func(uint32) (uint32, error)
+
+ mu sync.RWMutex
+ callers map[string]struct{}
+ persistMu sync.Mutex
+
+ signalLoop *dbusutil.SignalLoop
+}
+
+func newAllowCallerRegistry(service *dbusutil.Service) (*allowCallerRegistry, error) {
+ groupID, err := lookupGroupID(privilegedGroup)
+ if err != nil {
+ logger.Warningf("failed to resolve privileged group %s: %v", privilegedGroup, err)
+ groupID = invalidGroupID
+ }
+
+ registry, err := newAllowCallerRegistryWithConfig(localeHelperBus{service}, allowCallerStateFile, groupID)
+ if err != nil {
+ return nil, err
+ }
+ if err := registry.load(); err != nil {
+ logger.Warning("failed to load allow-caller state:", err)
+ }
+
+ registry.signalLoop = dbusutil.NewSignalLoop(service.Conn(), 10)
+ registry.signalLoop.Start()
+ dbusDaemon := ofdbus.NewDBus(service.Conn())
+ dbusDaemon.InitSignalExt(registry.signalLoop, true)
+ _, err = dbusDaemon.ConnectNameOwnerChanged(func(name, oldOwner, newOwner string) {
+ if strings.HasPrefix(name, ":") && oldOwner != "" && newOwner == "" {
+ registry.removeCaller(name)
+ }
+ })
+ if err != nil {
+ registry.signalLoop.Stop()
+ registry.signalLoop = nil
+ return nil, fmt.Errorf("watch allow-caller owner changes failed: %w", err)
+ }
+
+ return registry, nil
+}
+
+func newAllowCallerRegistryWithConfig(service allowCallerBus, stateFile string, privilegedGroupID uint32) (*allowCallerRegistry, error) {
+ busID, err := service.GetBusID()
+ if err != nil {
+ return nil, fmt.Errorf("get system bus ID failed: %w", err)
+ }
+ if busID == "" {
+ return nil, errors.New("system bus ID is empty")
+ }
+ return &allowCallerRegistry{
+ service: service,
+ stateFile: stateFile,
+ busID: busID,
+ privilegedGroupID: privilegedGroupID,
+ processGroups: getProcessGroups,
+ processParent: getProcessParentPID,
+ callers: make(map[string]struct{}),
+ }, nil
+}
+
+func (r *allowCallerRegistry) close() {
+ if r != nil && r.signalLoop != nil {
+ r.signalLoop.Stop()
+ }
+}
+
+func lookupGroupID(name string) (uint32, error) {
+ group, err := user.LookupGroup(name)
+ if err != nil {
+ return 0, err
+ }
+ value, err := strconv.ParseUint(group.Gid, 10, 32)
+ if err != nil {
+ return 0, fmt.Errorf("invalid gid %q for group %s: %w", group.Gid, name, err)
+ }
+ return uint32(value), nil
+}
+
+func getProcessGroups(pid uint32) ([]uint32, error) {
+ content, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
+ if err != nil {
+ return nil, err
+ }
+
+ var groups []uint32
+ for _, line := range strings.Split(string(content), "\n") {
+ if !strings.HasPrefix(line, "Gid:") && !strings.HasPrefix(line, "Groups:") {
+ continue
+ }
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ for _, value := range strings.Fields(parts[1]) {
+ gid, err := strconv.ParseUint(value, 10, 32)
+ if err != nil {
+ return nil, fmt.Errorf("invalid process gid %q: %w", value, err)
+ }
+ groups = append(groups, uint32(gid))
+ }
+ }
+ if len(groups) == 0 {
+ return nil, fmt.Errorf("no group credentials found for pid %d", pid)
+ }
+ return groups, nil
+}
+
+func getProcessParentPID(pid uint32) (uint32, error) {
+ content, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
+ if err != nil {
+ return 0, err
+ }
+
+ for _, line := range strings.Split(string(content), "\n") {
+ if !strings.HasPrefix(line, "PPid:") {
+ continue
+ }
+ fields := strings.Fields(strings.TrimPrefix(line, "PPid:"))
+ if len(fields) != 1 {
+ return 0, fmt.Errorf("invalid PPid entry for pid %d", pid)
+ }
+ parentPID, err := strconv.ParseUint(fields[0], 10, 32)
+ if err != nil {
+ return 0, fmt.Errorf("invalid parent pid %q for pid %d: %w", fields[0], pid, err)
+ }
+ return uint32(parentPID), nil
+ }
+ return 0, fmt.Errorf("no PPid entry found for pid %d", pid)
+}
+
+func isProcessDescendant(pid, ancestorPID uint32, processParent func(uint32) (uint32, error)) (bool, error) {
+ if pid == 0 || ancestorPID == 0 || pid == ancestorPID {
+ return false, nil
+ }
+
+ visited := make(map[uint32]struct{})
+ currentPID := pid
+ for currentPID != 0 {
+ if _, exists := visited[currentPID]; exists {
+ return false, fmt.Errorf("cycle detected in process ancestry at pid %d", currentPID)
+ }
+ visited[currentPID] = struct{}{}
+
+ parentPID, err := processParent(currentPID)
+ if err != nil {
+ return false, err
+ }
+ if parentPID == ancestorPID {
+ return true, nil
+ }
+ currentPID = parentPID
+ }
+ return false, nil
+}
+
+func (r *allowCallerRegistry) addCaller(sender dbus.Sender, uniqueName string) error {
+ if r == nil {
+ return errors.New("allow-caller registry is nil")
+ }
+ if sender == "" {
+ return errors.New("D-Bus sender is empty")
+ }
+ if !strings.HasPrefix(uniqueName, ":") {
+ return fmt.Errorf("invalid D-Bus unique name %q", uniqueName)
+ }
+
+ hasOwner, err := r.service.NameHasOwner(uniqueName)
+ if err != nil {
+ return fmt.Errorf("check D-Bus owner %q failed: %w", uniqueName, err)
+ }
+ if !hasOwner {
+ return fmt.Errorf("D-Bus caller %q has no owner", uniqueName)
+ }
+ if err := r.authorizeRegistrar(sender, uniqueName); err != nil {
+ return err
+ }
+
+ r.persistMu.Lock()
+ defer r.persistMu.Unlock()
+
+ r.mu.Lock()
+ if _, exists := r.callers[uniqueName]; exists {
+ r.mu.Unlock()
+ return nil
+ }
+ r.callers[uniqueName] = struct{}{}
+ callers := r.snapshotCallersLocked()
+ r.mu.Unlock()
+
+ if err := r.save(callers); err != nil {
+ r.mu.Lock()
+ delete(r.callers, uniqueName)
+ r.mu.Unlock()
+ return err
+ }
+
+ logger.Infof("registered allow-caller %s for %s", uniqueName, dbusServiceName)
+ return nil
+}
+
+func (r *allowCallerRegistry) authorizeRegistrar(sender dbus.Sender, uniqueName string) error {
+ senderUID, err := r.service.GetConnUID(string(sender))
+ if err != nil {
+ return fmt.Errorf("get SetAllowCaller sender %s UID failed: %w", sender, err)
+ }
+ if senderUID == 0 {
+ return nil
+ }
+ if r.privilegedGroupID == invalidGroupID {
+ return fmt.Errorf("privileged group %s is unavailable", privilegedGroup)
+ }
+
+ senderPID, err := r.service.GetConnPID(string(sender))
+ if err != nil {
+ return fmt.Errorf("get SetAllowCaller sender %s PID failed: %w", sender, err)
+ }
+ groups, err := r.processGroups(senderPID)
+ if err != nil {
+ return fmt.Errorf("get SetAllowCaller sender %s groups failed: %w", sender, err)
+ }
+ if !containsGroup(groups, r.privilegedGroupID) {
+ return fmt.Errorf("D-Bus caller %s is not in privileged group %s", sender, privilegedGroup)
+ }
+
+ targetUID, err := r.service.GetConnUID(uniqueName)
+ if err != nil {
+ return fmt.Errorf("get target caller %s UID failed: %w", uniqueName, err)
+ }
+ if targetUID != senderUID {
+ return fmt.Errorf("SetAllowCaller sender UID %d does not own target %s with UID %d", senderUID, uniqueName, targetUID)
+ }
+
+ targetPID, err := r.service.GetConnPID(uniqueName)
+ if err != nil {
+ return fmt.Errorf("get target caller %s PID failed: %w", uniqueName, err)
+ }
+ isDescendant, err := isProcessDescendant(targetPID, senderPID, r.processParent)
+ if err != nil {
+ return fmt.Errorf("verify target caller %s process ancestry failed: %w", uniqueName, err)
+ }
+ if !isDescendant {
+ return fmt.Errorf("target caller %s PID %d is not a descendant of SetAllowCaller sender %s PID %d",
+ uniqueName, targetPID, sender, senderPID)
+ }
+ return nil
+}
+
+func containsGroup(groups []uint32, target uint32) bool {
+ for _, group := range groups {
+ if group == target {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *allowCallerRegistry) authorize(sender dbus.Sender) error {
+ if r == nil {
+ return errors.New("allow-caller registry is nil")
+ }
+ if sender == "" {
+ return errors.New("D-Bus sender is empty")
+ }
+
+ uid, err := r.service.GetConnUID(string(sender))
+ if err != nil {
+ return fmt.Errorf("get caller %s UID failed: %w", sender, err)
+ }
+ if uid == 0 {
+ return nil
+ }
+
+ r.mu.RLock()
+ _, exists := r.callers[string(sender)]
+ r.mu.RUnlock()
+ if exists {
+ return nil
+ }
+ // Caller is not in the allow-caller registry. Fall back to Polkit authorization
+ // regardless of whether the registry is empty or the caller is simply not registered.
+ return errAllowCallerNotEnabled
+}
+
+func (r *allowCallerRegistry) removeCaller(uniqueName string) {
+ if r == nil || uniqueName == "" {
+ return
+ }
+
+ r.persistMu.Lock()
+ defer r.persistMu.Unlock()
+
+ r.mu.Lock()
+ if _, exists := r.callers[uniqueName]; !exists {
+ r.mu.Unlock()
+ return
+ }
+ delete(r.callers, uniqueName)
+ callers := r.snapshotCallersLocked()
+ r.mu.Unlock()
+
+ if err := r.save(callers); err != nil {
+ logger.Warningf("failed to persist removal of allow-caller %s: %v", uniqueName, err)
+ }
+}
+
+func (r *allowCallerRegistry) snapshotCallersLocked() []string {
+ callers := make([]string, 0, len(r.callers))
+ for caller := range r.callers {
+ callers = append(callers, caller)
+ }
+ sort.Strings(callers)
+ return callers
+}
+
+func (r *allowCallerRegistry) save(callers []string) error {
+ if r.busID == "" {
+ return errors.New("cannot persist allow-callers without a system bus ID")
+ }
+ state := persistedAllowCallers{BusID: r.busID, Callers: callers}
+ data, err := json.Marshal(state)
+ if err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(r.stateFile)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+
+ tmp, err := os.CreateTemp(dir, ".locale-helper-allow-callers-")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+ if err := tmp.Chmod(0600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpName, r.stateFile)
+}
+
+func (r *allowCallerRegistry) load() error {
+ r.persistMu.Lock()
+ defer r.persistMu.Unlock()
+
+ data, err := os.ReadFile(r.stateFile)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+
+ var state persistedAllowCallers
+ if err := json.Unmarshal(data, &state); err != nil {
+ return err
+ }
+ if state.BusID == "" || state.BusID != r.busID {
+ return nil
+ }
+
+ loaded := make(map[string]struct{})
+ for _, caller := range state.Callers {
+ if !strings.HasPrefix(caller, ":") {
+ continue
+ }
+ hasOwner, err := r.service.NameHasOwner(caller)
+ if err != nil {
+ logger.Warningf("failed to check persisted allow-caller %s: %v", caller, err)
+ continue
+ }
+ if hasOwner {
+ loaded[caller] = struct{}{}
+ }
+ }
+
+ r.mu.Lock()
+ for caller := range loaded {
+ r.callers[caller] = struct{}{}
+ }
+ r.mu.Unlock()
+ return nil
+}
\ No newline at end of file
diff --git a/locale-helper/allow_caller_test.go b/locale-helper/allow_caller_test.go
new file mode 100644
index 0000000..f1cc5c8
--- /dev/null
+++ b/locale-helper/allow_caller_test.go
@@ -0,0 +1,651 @@
+// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd.
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// mockAllowCallerBus implements allowCallerBus for testing without a real D-Bus.
+type mockAllowCallerBus struct {
+ busID string
+ nameHasOwner map[string]bool
+ connUID map[string]uint32
+ connPID map[string]uint32
+}
+
+func newMockBus() *mockAllowCallerBus {
+ return &mockAllowCallerBus{
+ busID: "mock-bus-id",
+ nameHasOwner: make(map[string]bool),
+ connUID: make(map[string]uint32),
+ connPID: make(map[string]uint32),
+ }
+}
+
+func (m *mockAllowCallerBus) NameHasOwner(name string) (bool, error) {
+ has, ok := m.nameHasOwner[name]
+ if !ok {
+ return false, fmt.Errorf("unknown name %q", name)
+ }
+ return has, nil
+}
+
+func (m *mockAllowCallerBus) GetConnUID(name string) (uint32, error) {
+ uid, ok := m.connUID[name]
+ if !ok {
+ return 0, fmt.Errorf("unknown name %q", name)
+ }
+ return uid, nil
+}
+
+func (m *mockAllowCallerBus) GetConnPID(name string) (uint32, error) {
+ pid, ok := m.connPID[name]
+ if !ok {
+ return 0, fmt.Errorf("unknown name %q", name)
+ }
+ return pid, nil
+}
+
+func (m *mockAllowCallerBus) GetBusID() (string, error) {
+ return m.busID, nil
+}
+
+// newRegistryForTest creates an allowCallerRegistry backed by a mock bus and
+// a temporary state file.
+func newRegistryForTest(t *testing.T, bus *mockAllowCallerBus) *allowCallerRegistry {
+ t.Helper()
+ stateFile := filepath.Join(t.TempDir(), "allow-callers.json")
+ r, err := newAllowCallerRegistryWithConfig(bus, stateFile, invalidGroupID)
+ require.NoError(t, err)
+ require.NotNil(t, r)
+ require.Equal(t, bus.busID, r.busID)
+ return r
+}
+
+// ---------------------------------------------------------------------------
+// containsGroup
+// ---------------------------------------------------------------------------
+
+func TestContainsGroup(t *testing.T) {
+ assert.True(t, containsGroup([]uint32{1, 2, 3}, 2))
+ assert.False(t, containsGroup([]uint32{1, 2, 3}, 4))
+ assert.False(t, containsGroup(nil, 1))
+ assert.False(t, containsGroup([]uint32{}, 1))
+}
+
+// ---------------------------------------------------------------------------
+// isProcessDescendant
+// ---------------------------------------------------------------------------
+
+func TestIsProcessDescendant(t *testing.T) {
+ // Simple ancestry: 5→3→1→0
+ parent := func(pid uint32) (uint32, error) {
+ switch pid {
+ case 5:
+ return 3, nil
+ case 3:
+ return 1, nil
+ case 1:
+ return 0, nil
+ }
+ return 0, nil
+ }
+
+ t.Run("pid is zero", func(t *testing.T) {
+ ok, err := isProcessDescendant(0, 3, parent)
+ assert.NoError(t, err)
+ assert.False(t, ok)
+ })
+
+ t.Run("ancestorPID is zero", func(t *testing.T) {
+ ok, err := isProcessDescendant(5, 0, parent)
+ assert.NoError(t, err)
+ assert.False(t, ok)
+ })
+
+ t.Run("pid equals ancestorPID", func(t *testing.T) {
+ ok, err := isProcessDescendant(5, 5, parent)
+ assert.NoError(t, err)
+ assert.False(t, ok)
+ })
+
+ t.Run("direct parent", func(t *testing.T) {
+ ok, err := isProcessDescendant(5, 3, parent)
+ assert.NoError(t, err)
+ assert.True(t, ok)
+ })
+
+ t.Run("indirect ancestor", func(t *testing.T) {
+ ok, err := isProcessDescendant(5, 1, parent)
+ assert.NoError(t, err)
+ assert.True(t, ok)
+ })
+
+ t.Run("no relationship", func(t *testing.T) {
+ ok, err := isProcessDescendant(5, 99, parent)
+ assert.NoError(t, err)
+ assert.False(t, ok)
+ })
+
+ t.Run("cycle detected", func(t *testing.T) {
+ cycle := func(pid uint32) (uint32, error) {
+ switch pid {
+ case 5:
+ return 3, nil
+ case 3:
+ return 5, nil
+ }
+ return 0, nil
+ }
+ ok, err := isProcessDescendant(5, 1, cycle)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cycle detected")
+ assert.False(t, ok)
+ })
+
+ t.Run("parent function error", func(t *testing.T) {
+ errFunc := func(pid uint32) (uint32, error) {
+ return 0, fmt.Errorf("mock error")
+ }
+ ok, err := isProcessDescendant(5, 3, errFunc)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "mock error")
+ assert.False(t, ok)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// snapshotCallersLocked
+// ---------------------------------------------------------------------------
+
+func TestSnapshotCallersLocked(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ r.callers[":1.100"] = struct{}{}
+ r.callers[":1.50"] = struct{}{}
+ r.callers[":1.200"] = struct{}{}
+
+ result := r.snapshotCallersLocked()
+ assert.Equal(t, []string{":1.100", ":1.200", ":1.50"}, result)
+}
+
+// ---------------------------------------------------------------------------
+// newAllowCallerRegistryWithConfig
+// ---------------------------------------------------------------------------
+
+func TestNewAllowCallerRegistryWithConfig(t *testing.T) {
+ t.Run("empty bus ID returns error", func(t *testing.T) {
+ bus := newMockBus()
+ bus.busID = ""
+ r, err := newAllowCallerRegistryWithConfig(bus, "/tmp/state.json", 42)
+ assert.Error(t, err)
+ assert.Nil(t, r)
+ })
+
+ t.Run("success", func(t *testing.T) {
+ bus := newMockBus()
+ r, err := newAllowCallerRegistryWithConfig(bus, "/tmp/state.json", 42)
+ assert.NoError(t, err)
+ assert.NotNil(t, r)
+ assert.Equal(t, "mock-bus-id", r.busID)
+ assert.Equal(t, uint32(42), r.privilegedGroupID)
+ assert.NotNil(t, r.callers)
+ assert.Len(t, r.callers, 0)
+ assert.NotNil(t, r.processGroups)
+ assert.NotNil(t, r.processParent)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// addCaller
+// ---------------------------------------------------------------------------
+
+func TestAddCallerNilRegistry(t *testing.T) {
+ var r *allowCallerRegistry
+ err := r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "nil")
+}
+
+func TestAddCallerEmptySender(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ err := r.addCaller(dbus.Sender(""), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+}
+
+func TestAddCallerInvalidUniqueName(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ err := r.addCaller(dbus.Sender(":1.0"), "no-prefix")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid D-Bus unique name")
+}
+
+func TestAddCallerNoOwner(t *testing.T) {
+ bus := newMockBus()
+ bus.nameHasOwner[":1.100"] = false
+ r := newRegistryForTest(t, bus)
+
+ err := r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "has no owner")
+}
+
+func TestAddCallerRootBypass(t *testing.T) {
+ bus := newMockBus()
+ bus.nameHasOwner[":1.100"] = true
+ bus.connUID[":1.0"] = 0
+ r := newRegistryForTest(t, bus)
+
+ err := r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ assert.NoError(t, err)
+
+ r.mu.RLock()
+ _, exists := r.callers[":1.100"]
+ r.mu.RUnlock()
+ assert.True(t, exists)
+}
+
+func TestAddCallerDuplicate(t *testing.T) {
+ bus := newMockBus()
+ bus.nameHasOwner[":1.100"] = true
+ bus.connUID[":1.0"] = 0
+ r := newRegistryForTest(t, bus)
+
+ err := r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ require.NoError(t, err)
+
+ err = r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ assert.NoError(t, err)
+
+ r.mu.RLock()
+ assert.Len(t, r.callers, 1)
+ r.mu.RUnlock()
+}
+
+func TestAddCallerPersistenceRollback(t *testing.T) {
+ bus := newMockBus()
+ bus.nameHasOwner[":1.100"] = true
+ bus.connUID[":1.0"] = 0
+ r := newRegistryForTest(t, bus)
+ r.busID = "" // trigger save failure
+
+ err := r.addCaller(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot persist")
+
+ r.mu.RLock()
+ _, exists := r.callers[":1.100"]
+ r.mu.RUnlock()
+ assert.False(t, exists, "caller must be removed from map after save failure")
+}
+
+func TestAddCallerAuthorizedSender(t *testing.T) {
+ bus := newMockBus()
+ senderName := dbus.Sender(":1.0")
+ targetName := ":1.100"
+ bus.nameHasOwner[targetName] = true
+ bus.connUID[string(senderName)] = 1000
+ bus.connUID[targetName] = 1000
+ bus.connPID[string(senderName)] = 5
+ bus.connPID[targetName] = 10
+
+ r := newRegistryForTest(t, bus)
+ r.privilegedGroupID = 42
+ r.processGroups = func(pid uint32) ([]uint32, error) {
+ return []uint32{42}, nil
+ }
+ r.processParent = func(pid uint32) (uint32, error) {
+ switch pid {
+ case 10:
+ return 5, nil
+ case 5:
+ return 1, nil
+ }
+ return 0, nil
+ }
+
+ err := r.addCaller(senderName, targetName)
+ assert.NoError(t, err)
+
+ r.mu.RLock()
+ _, exists := r.callers[targetName]
+ r.mu.RUnlock()
+ assert.True(t, exists)
+}
+
+func TestAddCallerUnauthorizedSender(t *testing.T) {
+ bus := newMockBus()
+ senderName := dbus.Sender(":1.0")
+ targetName := ":1.100"
+ bus.nameHasOwner[targetName] = true
+ bus.connUID[string(senderName)] = 1000
+
+ r := newRegistryForTest(t, bus)
+ // privilegedGroupID is invalidGroupID → non-root sender fails
+
+ err := r.addCaller(senderName, targetName)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "privileged group")
+}
+
+// ---------------------------------------------------------------------------
+// authorize
+// ---------------------------------------------------------------------------
+
+func TestAuthorizeNilRegistry(t *testing.T) {
+ var r *allowCallerRegistry
+ err := r.authorize(dbus.Sender(":1.0"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "nil")
+}
+
+func TestAuthorizeEmptySender(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ err := r.authorize(dbus.Sender(""))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+}
+
+func TestAuthorizeRootBypass(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 0
+ r := newRegistryForTest(t, bus)
+
+ err := r.authorize(dbus.Sender(":1.0"))
+ assert.NoError(t, err)
+}
+
+func TestAuthorizeAllowedCaller(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ r := newRegistryForTest(t, bus)
+ r.mu.Lock()
+ r.callers[":1.0"] = struct{}{}
+ r.mu.Unlock()
+
+ err := r.authorize(dbus.Sender(":1.0"))
+ assert.NoError(t, err)
+}
+
+func TestAuthorizeUnauthorizedCaller(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ r := newRegistryForTest(t, bus)
+
+ // Empty registry: not launched via deepin-security-loader, fall back to Polkit.
+ err := r.authorize(dbus.Sender(":1.0"))
+ assert.ErrorIs(t, err, errAllowCallerNotEnabled)
+
+ // Registry has other callers, this one is not registered either — also fall back.
+ r.mu.Lock()
+ r.callers[":1.9"] = struct{}{}
+ r.mu.Unlock()
+ err = r.authorize(dbus.Sender(":1.0"))
+ assert.ErrorIs(t, err, errAllowCallerNotEnabled)
+}
+
+// ---------------------------------------------------------------------------
+// removeCaller
+// ---------------------------------------------------------------------------
+
+func TestRemoveCallerNilRegistry(t *testing.T) {
+ var r *allowCallerRegistry
+ r.removeCaller(":1.100") // must not panic
+}
+
+func TestRemoveCallerEmptyName(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ r.removeCaller("") // must not panic
+}
+
+func TestRemoveCallerNonExistent(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ r.mu.Lock()
+ r.callers[":1.50"] = struct{}{}
+ r.mu.Unlock()
+
+ r.removeCaller(":1.100")
+
+ r.mu.RLock()
+ assert.Len(t, r.callers, 1)
+ _, exists := r.callers[":1.50"]
+ r.mu.RUnlock()
+ assert.True(t, exists)
+}
+
+func TestRemoveCallerExisting(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ r.mu.Lock()
+ r.callers[":1.100"] = struct{}{}
+ r.mu.Unlock()
+
+ r.removeCaller(":1.100")
+
+ r.mu.RLock()
+ _, exists := r.callers[":1.100"]
+ r.mu.RUnlock()
+ assert.False(t, exists)
+}
+
+// ---------------------------------------------------------------------------
+// save / load
+// ---------------------------------------------------------------------------
+
+func TestSaveEmptyBusID(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ r.busID = ""
+ err := r.save([]string{":1.100"})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot persist")
+}
+
+func TestSaveAndLoad(t *testing.T) {
+ stateFile := filepath.Join(t.TempDir(), "state.json")
+
+ // Save callers from registry r1.
+ bus1 := newMockBus()
+ r1, err := newAllowCallerRegistryWithConfig(bus1, stateFile, invalidGroupID)
+ require.NoError(t, err)
+
+ err = r1.save([]string{":1.100", ":1.200"})
+ assert.NoError(t, err)
+
+ t.Run("different bus ID skips stale state", func(t *testing.T) {
+ bus2 := newMockBus()
+ bus2.busID = "different-bus"
+ bus2.nameHasOwner[":1.100"] = true
+ bus2.nameHasOwner[":1.200"] = true
+ r2, err := newAllowCallerRegistryWithConfig(bus2, stateFile, invalidGroupID)
+ require.NoError(t, err)
+
+ err = r2.load()
+ assert.NoError(t, err)
+
+ r2.mu.RLock()
+ assert.Len(t, r2.callers, 0)
+ r2.mu.RUnlock()
+ })
+
+ t.Run("same bus ID restores callers", func(t *testing.T) {
+ bus3 := newMockBus()
+ bus3.nameHasOwner[":1.100"] = true
+ bus3.nameHasOwner[":1.200"] = true
+ r3, err := newAllowCallerRegistryWithConfig(bus3, stateFile, invalidGroupID)
+ require.NoError(t, err)
+
+ err = r3.load()
+ assert.NoError(t, err)
+
+ r3.mu.RLock()
+ assert.Len(t, r3.callers, 2)
+ _, has100 := r3.callers[":1.100"]
+ _, has200 := r3.callers[":1.200"]
+ r3.mu.RUnlock()
+ assert.True(t, has100)
+ assert.True(t, has200)
+ })
+}
+
+func TestLoadMissingFile(t *testing.T) {
+ r := newRegistryForTest(t, newMockBus())
+ err := r.load()
+ assert.NoError(t, err)
+ assert.Len(t, r.callers, 0)
+}
+
+func TestLoadStaleCallerSkipped(t *testing.T) {
+ stateFile := filepath.Join(t.TempDir(), "state.json")
+
+ // Persist :1.100 and :1.200, then :1.200 disappears.
+ bus := newMockBus()
+ r, err := newAllowCallerRegistryWithConfig(bus, stateFile, invalidGroupID)
+ require.NoError(t, err)
+ err = r.save([]string{":1.100", ":1.200"})
+ require.NoError(t, err)
+
+ bus2 := newMockBus()
+ bus2.busID = bus.busID
+ bus2.nameHasOwner[":1.100"] = true
+ bus2.nameHasOwner[":1.200"] = false
+ r2, err := newAllowCallerRegistryWithConfig(bus2, stateFile, invalidGroupID)
+ require.NoError(t, err)
+
+ err = r2.load()
+ assert.NoError(t, err)
+
+ r2.mu.RLock()
+ assert.Len(t, r2.callers, 1)
+ _, has100 := r2.callers[":1.100"]
+ _, has200 := r2.callers[":1.200"]
+ r2.mu.RUnlock()
+ assert.True(t, has100)
+ assert.False(t, has200, "stale caller without owner must be filtered out")
+}
+
+// ---------------------------------------------------------------------------
+// authorizeRegistrar (direct coverage of the authorization gate)
+// ---------------------------------------------------------------------------
+
+func TestAuthorizeRegistrar(t *testing.T) {
+ t.Run("root sender bypass", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 0
+ r := newRegistryForTest(t, bus)
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.NoError(t, err)
+ })
+
+ t.Run("unavailable privileged group", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ r := newRegistryForTest(t, bus)
+ // r.privilegedGroupID is invalidGroupID
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "privileged group")
+ })
+
+ t.Run("sender not in privileged group", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ bus.connPID[":1.0"] = 5
+ r := newRegistryForTest(t, bus)
+ r.privilegedGroupID = 42
+ r.processGroups = func(pid uint32) ([]uint32, error) {
+ return []uint32{99}, nil
+ }
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not in privileged group")
+ })
+
+ t.Run("target UID mismatch", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ bus.connPID[":1.0"] = 5
+ bus.connUID[":1.100"] = 2000
+ r := newRegistryForTest(t, bus)
+ r.privilegedGroupID = 42
+ r.processGroups = func(pid uint32) ([]uint32, error) {
+ return []uint32{42}, nil
+ }
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "does not own target")
+ })
+
+ t.Run("target not a descendant", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ bus.connPID[":1.0"] = 5
+ bus.connUID[":1.100"] = 1000
+ bus.connPID[":1.100"] = 10
+ r := newRegistryForTest(t, bus)
+ r.privilegedGroupID = 42
+ r.processGroups = func(pid uint32) ([]uint32, error) {
+ return []uint32{42}, nil
+ }
+ r.processParent = func(pid uint32) (uint32, error) {
+ switch pid {
+ case 10:
+ return 3, nil
+ case 3:
+ return 1, nil
+ case 5:
+ return 1, nil
+ }
+ return 0, nil
+ }
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not a descendant")
+ })
+
+ t.Run("successful authorization", func(t *testing.T) {
+ bus := newMockBus()
+ bus.connUID[":1.0"] = 1000
+ bus.connPID[":1.0"] = 5
+ bus.connUID[":1.100"] = 1000
+ bus.connPID[":1.100"] = 10
+ r := newRegistryForTest(t, bus)
+ r.privilegedGroupID = 42
+ r.processGroups = func(pid uint32) ([]uint32, error) {
+ return []uint32{42}, nil
+ }
+ r.processParent = func(pid uint32) (uint32, error) {
+ switch pid {
+ case 10:
+ return 5, nil
+ case 5:
+ return 1, nil
+ }
+ return 0, nil
+ }
+ err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100")
+ assert.NoError(t, err)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// lookupGroupID (OS integration smoke test)
+// ---------------------------------------------------------------------------
+
+func TestLookupGroupID(t *testing.T) {
+ _, err := lookupGroupID("root")
+ if err != nil {
+ t.Skipf("group 'root' not found on this system: %v; skipping", err)
+ }
+ // If the root group exists, verify it has GID 0.
+ gid, err := lookupGroupID("root")
+ require.NoError(t, err)
+ assert.Equal(t, uint32(0), gid)
+}
\ No newline at end of file
diff --git a/locale-helper/exported_methods_auto.go b/locale-helper/exported_methods_auto.go
index 14e681b..412df02 100644
--- a/locale-helper/exported_methods_auto.go
+++ b/locale-helper/exported_methods_auto.go
@@ -18,5 +18,10 @@ func (v *Helper) GetExportedMethods() dbusutil.ExportedMethods {
Fn: v.SetLocale,
InArgs: []string{"locale"},
},
+ {
+ Name: "SetAllowCaller",
+ Fn: v.SetAllowCaller,
+ InArgs: []string{"uniqueName"},
+ },
}
}
diff --git a/locale-helper/ifc.go b/locale-helper/ifc.go
index 07801ed..2fe3ec7 100644
--- a/locale-helper/ifc.go
+++ b/locale-helper/ifc.go
@@ -5,6 +5,7 @@
package main
import (
+ "errors"
"fmt"
"github.com/godbus/dbus/v5"
@@ -25,9 +26,12 @@ var errAuthFailed = fmt.Errorf("authentication failed")
func (h *Helper) SetLocale(sender dbus.Sender, locale string) *dbus.Error {
h.service.DelayAutoQuit()
- ok, err := h.checkAuth(sender)
- logger.Debug("---Auth ret:", ok, err)
- if !ok || err != nil {
+ authorized, err := h.authorizeOrPolkit(sender)
+ if err != nil {
+ logger.Warning("SetLocale access denied:", err)
+ return dbusutil.ToError(err)
+ }
+ if !authorized {
return dbusutil.ToError(errAuthFailed)
}
@@ -54,9 +58,12 @@ func (h *Helper) emitRealSuccess() {
}
func (h *Helper) generateLocale(sender dbus.Sender, locale string) error {
- ok, err := h.checkAuth(sender)
- logger.Debug("---Auth ret:", ok, err)
- if !ok || err != nil {
+ authorized, err := h.authorizeOrPolkit(sender)
+ if err != nil {
+ logger.Warning("GenerateLocale access denied:", err)
+ return err
+ }
+ if !authorized {
return errAuthFailed
}
@@ -86,6 +93,15 @@ func (h *Helper) generateLocale(sender dbus.Sender, locale string) error {
return nil
}
+func (h *Helper) SetAllowCaller(sender dbus.Sender, uniqueName string) *dbus.Error {
+ h.service.DelayAutoQuit()
+ err := h.allowCallers.addCaller(sender, uniqueName)
+ if err != nil {
+ logger.Warningf("SetAllowCaller rejected sender %s for target %s: %v", sender, uniqueName, err)
+ }
+ return dbusutil.ToError(err)
+}
+
func (h *Helper) GenerateLocale(sender dbus.Sender, locale string) *dbus.Error {
h.service.DelayAutoQuit()
@@ -128,6 +144,21 @@ func enableLocaleInFile(locale, file string) error {
return nil
}
+// authorizeOrPolkit checks the allow-caller registry first. If the registry is
+// not enabled (the service was not launched through deepin-security-loader),
+// fall back to the Polkit authorization dialog.
+func (h *Helper) authorizeOrPolkit(sender dbus.Sender) (bool, error) {
+ err := h.allowCallers.authorize(sender)
+ if err == nil {
+ return true, nil
+ }
+ if errors.Is(err, errAllowCallerNotEnabled) {
+ // Not launched via deepin-security-loader: fall back to Polkit.
+ return h.checkAuth(sender)
+ }
+ return false, err
+}
+
func (h *Helper) checkAuth(sender dbus.Sender) (bool, error) {
systemBus := h.service.Conn()
authority := polkit.NewAuthority(systemBus)
diff --git a/locale-helper/main.go b/locale-helper/main.go
index ccd0cf6..470d068 100644
--- a/locale-helper/main.go
+++ b/locale-helper/main.go
@@ -25,9 +25,10 @@ const (
)
type Helper struct {
- service *dbusutil.Service
- mu sync.Mutex
- running bool
+ service *dbusutil.Service
+ allowCallers *allowCallerRegistry
+ mu sync.Mutex
+ running bool
//nolint
signals *struct {
@@ -62,9 +63,16 @@ func main() {
logger.Fatalf("name %q already has the owner", dbusServiceName)
}
+ allowCallers, err := newAllowCallerRegistry(service)
+ if err != nil {
+ logger.Fatal("failed to initialize allow-caller registry:", err)
+ }
+ defer allowCallers.close()
+
var h = &Helper{
- running: false,
- service: service,
+ running: false,
+ service: service,
+ allowCallers: allowCallers,
}
err = service.Export(dbusPath, h)
if err != nil {
diff --git a/misc/conf/org.deepin.dde.LocaleHelper1.conf b/misc/conf/org.deepin.dde.LocaleHelper1.conf
index b85737f..34e7534 100644
--- a/misc/conf/org.deepin.dde.LocaleHelper1.conf
+++ b/misc/conf/org.deepin.dde.LocaleHelper1.conf
@@ -8,6 +8,15 @@
+
+
+
+
+
@@ -20,6 +29,10 @@
send_interface="org.freedesktop.DBus.Properties"/>
+
+
-
+
\ No newline at end of file
diff --git a/misc/polkit-action/org.deepin.dde.locale-helper.policy.in b/misc/polkit-action/org.deepin.dde.locale-helper.policy.in
index 04501ec..a825f9a 100644
--- a/misc/polkit-action/org.deepin.dde.locale-helper.policy.in
+++ b/misc/polkit-action/org.deepin.dde.locale-helper.policy.in
@@ -12,7 +12,7 @@
no
no
- yes
+ auth_admin_keep
diff --git a/misc/systemd/system/deepin-locale-helper.service b/misc/systemd/system/deepin-locale-helper.service
index 9d3f36d..ee1732b 100644
--- a/misc/systemd/system/deepin-locale-helper.service
+++ b/misc/systemd/system/deepin-locale-helper.service
@@ -10,6 +10,11 @@ Type=dbus
BusName=org.deepin.dde.LocaleHelper1
ExecStart=/usr/lib/deepin-api/locale-helper
+RuntimeDirectory=dde-api
+RuntimeDirectoryMode=0700
+RuntimeDirectoryPreserve=yes
+
+ReadWritePaths=/run/dde-api
ReadWritePaths=/etc/default/locale
ReadWritePaths=/etc/locale.gen