diff --git a/core/cmd/dms/shellapp.go b/core/cmd/dms/shellapp.go index 169881eda..3fe0c7708 100644 --- a/core/cmd/dms/shellapp.go +++ b/core/cmd/dms/shellapp.go @@ -9,12 +9,14 @@ import ( "strings" "github.com/duxlabs-dev/dux-go/shellapp" + "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" "github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/duxlabs-dev/dux-shell/core/internal/gpu" "github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/duxlabs-dev/dux-shell/core/internal/shellembed" "github.com/duxlabs-dev/dux-shell/core/internal/utils" + "github.com/spf13/afero" ) var shellApp = shellapp.New(shellapp.Config{ @@ -121,6 +123,11 @@ func dmsExtraEnv(string) []string { env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000") } env = append(env, gpu.EGLVendorEnv()...) + policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs()) + if err != nil { + log.Errorf("CLI policy unreadable, enforcing every shell switch: %v", err) + } + env = append(env, policyEnv...) // systemd user services miss the login shell PATH, so tools in ~/.local/bin // (herdr, pip/cargo installs) would be invisible to the shell and its children. for _, entry := range utils.EnvWithUserBinPath(nil) { diff --git a/core/internal/clipolicy/clipolicy.go b/core/internal/clipolicy/clipolicy.go index deb454078..8c135871c 100644 --- a/core/internal/clipolicy/clipolicy.go +++ b/core/internal/clipolicy/clipolicy.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/spf13/afero" ) @@ -14,11 +15,28 @@ const ( ) type File struct { - PolicyVersion int `json:"policy_version"` - ImmutableSystem *bool `json:"immutable_system"` - BlockedCommands *[]string `json:"blocked_commands"` - Message *string `json:"message"` - DisableRegistries *bool `json:"disable_registries"` + PolicyVersion int `json:"policy_version"` + ImmutableSystem *bool `json:"immutable_system"` + BlockedCommands *[]string `json:"blocked_commands"` + Message *string `json:"message"` + DisableRegistries *bool `json:"disable_registries"` + DisableLockIPCUnlock *bool `json:"disable_lock_ipc_unlock"` + DisableLogindUnlock *bool `json:"disable_logind_unlock"` + DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"` + DisableUserPlugins *bool `json:"disable_user_plugins"` + DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"` +} + +// shellSwitches reach the shell as DMS_POLICY_ in its launch environment. +var shellSwitches = []struct { + key string + value func(*File) *bool +}{ + {"disable_lock_ipc_unlock", func(p *File) *bool { return p.DisableLockIPCUnlock }}, + {"disable_logind_unlock", func(p *File) *bool { return p.DisableLogindUnlock }}, + {"disable_custom_lock_pam", func(p *File) *bool { return p.DisableCustomLockPAM }}, + {"disable_user_plugins", func(p *File) *bool { return p.DisableUserPlugins }}, + {"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }}, } // LoadFile returns nil without an error when the file does not exist. @@ -39,18 +57,65 @@ func LoadFile(fs afero.Fs, path string) (*File, error) { return &policy, nil } -// RegistriesDisabled reports whether the policy turns off the plugin and theme -// registries. An explicit value in AdminPath overrides PackagedPath. -func RegistriesDisabled(fs afero.Fs) (bool, error) { - disabled := false +// load returns the policy files that exist, PackagedPath first. +func load(fs afero.Fs) ([]*File, error) { + var policies []*File for _, path := range []string{PackagedPath, AdminPath} { policy, err := LoadFile(fs, path) if err != nil { - return false, err + return nil, err } - if policy != nil && policy.DisableRegistries != nil { - disabled = *policy.DisableRegistries + if policy != nil { + policies = append(policies, policy) } } - return disabled, nil + return policies, nil +} + +// flag reports a boolean key, false unless set. An explicit value in AdminPath +// overrides PackagedPath. +func flag(policies []*File, value func(*File) *bool) bool { + set := false + for _, policy := range policies { + if v := value(policy); v != nil { + set = *v + } + } + return set +} + +// RegistriesDisabled reports whether the policy turns off the plugin and theme +// registries. An explicit value in AdminPath overrides PackagedPath. +func RegistriesDisabled(fs afero.Fs) (bool, error) { + policies, err := load(fs) + if err != nil { + return false, err + } + return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil +} + +// LogindUnlockDisabled reports whether a logind Unlock must leave a lock in +// place. An explicit value in AdminPath overrides PackagedPath. +func LogindUnlockDisabled(fs afero.Fs) (bool, error) { + policies, err := load(fs) + if err != nil { + return false, err + } + return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil +} + +// ShellEnv returns every shell switch as DMS_POLICY_=1 or 0, so a value +// inherited by the shell never counts. A policy file that fails to load turns +// every switch on, and its error is returned with them. +func ShellEnv(fs afero.Fs) ([]string, error) { + policies, err := load(fs) + env := make([]string, 0, len(shellSwitches)) + for _, s := range shellSwitches { + value := "0" + if err != nil || flag(policies, s.value) { + value = "1" + } + env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value) + } + return env, err } diff --git a/core/internal/clipolicy/clipolicy_test.go b/core/internal/clipolicy/clipolicy_test.go index a4c3753da..4b0e99230 100644 --- a/core/internal/clipolicy/clipolicy_test.go +++ b/core/internal/clipolicy/clipolicy_test.go @@ -1,6 +1,7 @@ package clipolicy import ( + "strings" "testing" "github.com/spf13/afero" @@ -45,3 +46,103 @@ func TestRegistriesDisabled(t *testing.T) { }) } } + +func TestLogindUnlockDisabled(t *testing.T) { + tests := []struct { + name string + packaged string + admin string + want bool + wantErr string + }{ + {name: "no policy files"}, + {name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true}, + {name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`}, + {name: "another key", admin: `{"disable_lock_ipc_unlock": true}`}, + {name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tt.packaged != "" { + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644)) + } + if tt.admin != "" { + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644)) + } + + disabled, err := LogindUnlockDisabled(fs) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, disabled) + }) + } +} + +var shellSwitchEnv = map[string]string{ + "disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK", + "disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK", + "disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM", + "disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS", + "disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET", +} + +func TestShellEnv(t *testing.T) { + tests := []struct { + name string + packaged string + admin string + on bool + }{ + {name: "no policy files"}, + {name: "packaged policy", packaged: `{"KEY": true}`, on: true}, + {name: "admin policy", admin: `{"KEY": true}`, on: true}, + {name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`}, + {name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true}, + } + + for key, name := range shellSwitchEnv { + for _, tt := range tests { + t.Run(key+"/"+tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tt.packaged != "" { + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644)) + } + if tt.admin != "" { + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644)) + } + + var want []string + for _, other := range shellSwitchEnv { + if other == name && tt.on { + want = append(want, other+"=1") + } else { + want = append(want, other+"=0") + } + } + + env, err := ShellEnv(fs) + require.NoError(t, err) + assert.ElementsMatch(t, want, env) + }) + } + } +} + +func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644)) + + var want []string + for _, name := range shellSwitchEnv { + want = append(want, name+"=1") + } + + env, err := ShellEnv(fs) + require.ErrorContains(t, err, "failed to parse "+AdminPath) + assert.ElementsMatch(t, want, env) +} diff --git a/core/internal/server/loginctl/manager.go b/core/internal/server/loginctl/manager.go index 2a96485bd..dbbf3e6e4 100644 --- a/core/internal/server/loginctl/manager.go +++ b/core/internal/server/loginctl/manager.go @@ -11,7 +11,7 @@ import ( "github.com/godbus/dbus/v5" ) -func NewManager() (*Manager, error) { +func NewManager(keepLockOnUnlock bool) (*Manager, error) { conn, err := dbus.SystemBus() if err != nil { return nil, fmt.Errorf("failed to connect to system bus: %w", err) @@ -25,6 +25,8 @@ func NewManager() (*Manager, error) { conn: conn, dirty: make(chan struct{}, 1), signals: make(chan *dbus.Signal, 256), + + keepLockOnUnlock: keepLockOnUnlock, } m.sleepInhibitorEnabled.Store(true) diff --git a/core/internal/server/loginctl/monitor.go b/core/internal/server/loginctl/monitor.go index ea46385d9..900ddbc36 100644 --- a/core/internal/server/loginctl/monitor.go +++ b/core/internal/server/loginctl/monitor.go @@ -30,13 +30,22 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) { } case dbusSessionInterface + ".Unlock": - lockedHint := m.sessionLockedHint() - m.stateMutex.Lock() - m.state.Locked = false - m.state.LockedHint = lockedHint - m.stateMutex.Unlock() - m.notifySubscribers() - m.prelockedReady.Store(false) + if m.keepLockOnUnlock { + // Only the locker clears LockedHint, so take both from logind, and + // keep the cached state when logind cannot be read. + if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked { + m.prelockedReady.Store(false) + } + m.notifySubscribers() + } else { + lockedHint := m.sessionLockedHint() + m.stateMutex.Lock() + m.state.Locked = false + m.state.LockedHint = lockedHint + m.stateMutex.Unlock() + m.notifySubscribers() + m.prelockedReady.Store(false) + } // Cancel the lock timer if it's still running m.lockTimerMu.Lock() diff --git a/core/internal/server/loginctl/monitor_test.go b/core/internal/server/loginctl/monitor_test.go index 1c9b3c02c..4e3e37dd2 100644 --- a/core/internal/server/loginctl/monitor_test.go +++ b/core/internal/server/loginctl/monitor_test.go @@ -37,42 +37,58 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) { func TestManager_HandleDBusSignal_Unlock(t *testing.T) { tests := []struct { - name string - lockedHint bool + name string + keepLock bool + call *dbus.Call // logind's GetAll answer; nil means no session object + wantLocked bool + wantLockedHint bool }{ - {name: "logind cleared LockedHint", lockedHint: false}, - {name: "logind still reports LockedHint", lockedHint: true}, + {name: "logind cleared LockedHint", call: lockedHintCall(false)}, + {name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true}, + {name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}}, + {name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true}, + {name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)}, + {name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true}, + {name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sessionObj := mockdbus.NewMockBusObject(t) - sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{ - Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}}, - }) - manager := &Manager{ state: &SessionState{ Locked: true, LockedHint: true, }, - stateMutex: sync.RWMutex{}, - dirty: make(chan struct{}, 1), - sessionObj: sessionObj, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + keepLockOnUnlock: tt.keepLock, } + if tt.call != nil { + sessionObj := mockdbus.NewMockBusObject(t) + sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call) + manager.sessionObj = sessionObj + } + manager.markLockerReady() manager.handleDBusSignal(&dbus.Signal{ Name: "org.freedesktop.login1.Session.Unlock", }) + assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load()) manager.stateMutex.RLock() defer manager.stateMutex.RUnlock() - assert.False(t, manager.state.Locked) - assert.Equal(t, tt.lockedHint, manager.state.LockedHint) + assert.Equal(t, tt.wantLocked, manager.state.Locked) + assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint) }) } } +func lockedHintCall(lockedHint bool) *dbus.Call { + return &dbus.Call{Body: []any{map[string]dbus.Variant{ + "LockedHint": dbus.MakeVariant(lockedHint), + }}} +} + func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) { t.Run("preparing for sleep - true", func(t *testing.T) { manager := &Manager{ diff --git a/core/internal/server/loginctl/types.go b/core/internal/server/loginctl/types.go index e72d30b38..31811b72f 100644 --- a/core/internal/server/loginctl/types.go +++ b/core/internal/server/loginctl/types.go @@ -69,4 +69,5 @@ type Manager struct { lockTimer *time.Timer sleepInhibitorEnabled atomic.Bool fallbackDelay time.Duration + keepLockOnUnlock bool } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 54f3148c0..5eaa1c0b2 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/syncmap" + "github.com/duxlabs-dev/dux-shell/core/internal/clipolicy" "github.com/duxlabs-dev/dux-shell/core/internal/geolocation" "github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/matugen" @@ -37,6 +38,7 @@ import ( "github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput" "github.com/duxlabs-dev/dux-shell/core/internal/systemd" "github.com/duxlabs-dev/dux-shell/core/internal/utils" + "github.com/spf13/afero" ) const APIVersion = 38 @@ -108,7 +110,12 @@ func InitializeNetworkManager() error { } func InitializeLoginctlManager() error { - manager, err := loginctl.NewManager() + keepLockOnUnlock, err := clipolicy.LogindUnlockDisabled(afero.NewOsFs()) + if err != nil { + log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err) + keepLockOnUnlock = true + } + manager, err := loginctl.NewManager(keepLockOnUnlock) if err != nil { log.Warnf("Failed to initialize loginctl manager: %v", err) return err diff --git a/quickshell/Common/AdminPolicy.qml b/quickshell/Common/AdminPolicy.qml new file mode 100644 index 000000000..35e71312a --- /dev/null +++ b/quickshell/Common/AdminPolicy.qml @@ -0,0 +1,12 @@ +pragma Singleton + +import Quickshell + +// cli-policy.json switches, set by `dms run` on each launch (core/internal/clipolicy). +Singleton { + readonly property bool disableLockIpcUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK") === "1" + readonly property bool disableLogindUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOGIND_UNLOCK") === "1" + readonly property bool disableCustomLockPam: Quickshell.env("DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM") === "1" + readonly property bool disableUserPlugins: Quickshell.env("DMS_POLICY_DISABLE_USER_PLUGINS") === "1" + readonly property bool disableSettingsIpcSet: Quickshell.env("DMS_POLICY_DISABLE_SETTINGS_IPC_SET") === "1" +} diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index f68da0d91..0534ae61e 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1102,6 +1102,8 @@ Item { } function set(key: string, value: string): string { + if (AdminPolicy.disableSettingsIpcSet) + return "SETTINGS_SET_DISABLED"; if (!(key in SettingsData)) { log.warn("Cannot set property, not found:", key); return "SETTINGS_INVALID_KEY"; diff --git a/quickshell/Modules/Lock/Lock.qml b/quickshell/Modules/Lock/Lock.qml index 398013dc3..30d4f1ad4 100644 --- a/quickshell/Modules/Lock/Lock.qml +++ b/quickshell/Modules/Lock/Lock.qml @@ -220,6 +220,8 @@ Scope { function onSessionUnlocked() { customLockerSpawned = false; + if (AdminPolicy.disableLogindUnlock) + return; if (pendingLock) { pendingLock = false; lockInitiatedLocally = false; @@ -412,14 +414,20 @@ Scope { } function unlock() { + if (AdminPolicy.disableLockIpcUnlock) + return; root.unlock(); } function forceReset() { + if (AdminPolicy.disableLockIpcUnlock) + return; root.forceReset(); } function demo() { + if (AdminPolicy.disableLockIpcUnlock) + return; demoWindow.showDemo(); } diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 9af00636e..bccaa23d1 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -10,6 +10,8 @@ import qs.Services Scope { id: root + readonly property var log: Log.scoped("Pam") + property bool lockSecured: false property bool unlockInProgress: false @@ -96,6 +98,8 @@ Scope { if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) { u2f.startForSecondFactor(); } else { + if (AdminPolicy.disableCustomLockPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && !u2fConfigWatcher.loaded) + log.warn("/etc/pam.d/dankshell-u2f is missing, unlocking without the security key"); completeUnlock(); } } @@ -113,8 +117,8 @@ Scope { } readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded - readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint) - readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f) + readonly property bool fprintSuppressedByPrimaryPam: !AdminPolicy.disableCustomLockPam && (SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint)) + readonly property bool u2fSuppressedByPrimaryPam: !AdminPolicy.disableCustomLockPam && (SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f)) readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded FileView { @@ -139,6 +143,13 @@ Scope { printErrors: false } + FileView { + id: fprintConfigWatcher + + path: AdminPolicy.disableCustomLockPam ? "/etc/pam.d/dankshell-fprint" : "" + printErrors: false + } + FileView { id: customU2fPamWatcher @@ -169,7 +180,7 @@ Scope { } function ensureUserPamConfig(): void { - if (SettingsData.lockPamExternallyManaged || resolveUserPam.running) + if (AdminPolicy.disableCustomLockPam || SettingsData.lockPamExternallyManaged || resolveUserPam.running) return; resolveUserPam.running = true; } @@ -180,6 +191,8 @@ Scope { id: passwd config: { + if (AdminPolicy.disableCustomLockPam) + return dankshellConfigWatcher.loaded ? "dankshell" : "login"; if (root.customPamActive) return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1); if (SettingsData.lockPamExternallyManaged) @@ -191,6 +204,8 @@ Scope { return "login"; } configDirectory: { + if (AdminPolicy.disableCustomLockPam) + return "/etc/pam.d"; if (root.customPamActive) { const idx = SettingsData.lockPamPath.lastIndexOf("/"); return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/"; @@ -299,14 +314,15 @@ Scope { PamContext { id: fprint - readonly property bool available: SettingsData.lockFingerprintReady + readonly property bool available: SettingsData.lockFingerprintReady && (!AdminPolicy.disableCustomLockPam || fprintConfigWatcher.loaded) property int tries: 0 property int errorTries: 0 property double attemptStartedAt: 0 property bool completedDuringStart: false readonly property int maxErrorTries: 200 readonly property int daemonIdleExitMs: 30000 - // sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint + // sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint; under the + // policy, /etc/pam.d/dankshell-fprint must carry the same value readonly property int sessionTimeoutMs: 90000 readonly property int verifyStartSlackMs: 10000 readonly property bool retrying: errorRetry.running @@ -401,8 +417,8 @@ Scope { Qt.callLater(checkAvail); } - config: "fprint" - configDirectory: Quickshell.shellDir + "/assets/pam" + config: AdminPolicy.disableCustomLockPam ? "dankshell-fprint" : "fprint" + configDirectory: AdminPolicy.disableCustomLockPam ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" onPamMessage: { if (attemptStartedAt === 0) @@ -446,7 +462,7 @@ Scope { PamContext { id: u2f - property bool available: SettingsData.lockU2fReady + property bool available: SettingsData.lockU2fReady && (!AdminPolicy.disableCustomLockPam || u2fConfigWatcher.loaded) function checkAvail(): void { if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) { @@ -483,11 +499,15 @@ Scope { } config: { + if (AdminPolicy.disableCustomLockPam) + return "dankshell-u2f"; if (root.customU2fPamActive) return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1); return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f"; } configDirectory: { + if (AdminPolicy.disableCustomLockPam) + return "/etc/pam.d"; if (root.customU2fPamActive) { const idx = SettingsData.lockU2fPamPath.lastIndexOf("/"); return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/"; @@ -694,6 +714,8 @@ Scope { } function onLockPamExternallyManagedChanged(): void { + if (AdminPolicy.disableCustomLockPam) + return; root.resetAuthFlows(); if (!SettingsData.lockPamExternallyManaged) root.ensureUserPamConfig(); @@ -702,6 +724,8 @@ Scope { } function onLockU2fPamPathChanged(): void { + if (AdminPolicy.disableCustomLockPam) + return; u2f.abort(); u2f.checkAvail(); } diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index 132cfb7ee..bbd748ec4 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -1051,6 +1051,9 @@ Singleton { } function getDmsActions() { - return Actions.getDmsActions(CompositorService.isNiri, CompositorService.isHyprland); + const actions = Actions.getDmsActions(CompositorService.isNiri, CompositorService.isHyprland); + if (!AdminPolicy.disableLockIpcUnlock) + return actions; + return actions.filter(action => action.id !== "spawn dms ipc call lock demo"); } } diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index 32b849f80..d38cc2b28 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -94,7 +94,8 @@ Singleton { } Component.onCompleted: { - userWatcher.folder = Paths.toFileUrl(root.pluginDirectory); + // A FolderListModel never given a folder lists the working directory. + userWatcher.folder = AdminPolicy.disableUserPlugins ? "" : Paths.toFileUrl(root.pluginDirectory); systemWatcher.folder = Paths.toFileUrl(root.systemPluginDirectory); Qt.callLater(resyncAll); Qt.callLater(checkPluginDirectoryExists); @@ -150,7 +151,7 @@ Singleton { } function resyncAll() { - const userList = snapshotModel(userWatcher, "user"); + const userList = AdminPolicy.disableUserPlugins ? [] : snapshotModel(userWatcher, "user"); const sysList = snapshotModel(systemWatcher, "system"); const seenPaths = {}; const modifiedTimes = {}; @@ -1154,7 +1155,8 @@ Singleton { const userUrl = Paths.toFileUrl(root.pluginDirectory); const systemUrl = Paths.toFileUrl(root.systemPluginDirectory); userWatcher.folder = ""; - userWatcher.folder = userUrl; + if (!AdminPolicy.disableUserPlugins) + userWatcher.folder = userUrl; systemWatcher.folder = ""; systemWatcher.folder = systemUrl; resyncDebounce.restart(); diff --git a/quickshell/tests/admin-policy.test.mjs b/quickshell/tests/admin-policy.test.mjs new file mode 100644 index 000000000..f6623b47e --- /dev/null +++ b/quickshell/tests/admin-policy.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { loadScript } from "./qml-script.mjs"; + +function functionSource(file, name) { + const source = readFileSync(new URL(file, import.meta.url), "utf8"); + const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\)(?:: \\w+)? \\{\\n[\\s\\S]*?^ \\}`, "m")); + if (!found) + throw new Error(`${name} not found in ${file}`); + return found[0]; +} + +test("the lock IPC switch drops the lock demo keybind action and keeps the others", () => { + const getDmsActions = functionSource("../Services/KeybindsService.qml", "getDmsActions"); + const Actions = loadScript(new URL("../Common/KeybindActions.js", import.meta.url)); + for (const disableLockIpcUnlock of [false, true]) { + const context = vm.createContext({ Actions, CompositorService: { isNiri: false, isHyprland: false }, AdminPolicy: { disableLockIpcUnlock } }); + vm.runInContext(getDmsActions, context); + const ids = context.getDmsActions().map(action => action.id); + assert.equal(ids.includes("spawn dms ipc call lock demo"), !disableLockIpcUnlock); + assert.ok(ids.includes("spawn dms ipc call lock lock")); + assert.equal(ids.length, Actions.getDmsActions(false, false).length - (disableLockIpcUnlock ? 1 : 0)); + } +}); + +test("the user plugin switch keeps the user tree out of a scan and a resync", () => { + const user = "/home/user/.config/DankMaterialShell/plugins"; + const system = "/etc/xdg/quickshell/dms-plugins"; + const folder = dirs => ({ folder: "", count: dirs.length, get: (i, role) => role === "filePath" ? "file://" + dirs[i] : 0 }); + for (const disableUserPlugins of [false, true]) { + const loaded = []; + const context = vm.createContext({ + Date, + AdminPolicy: { disableUserPlugins }, + Paths: { toFileUrl: path => "file://" + path }, + pluginDirectory: user, + systemPluginDirectory: system, + userWatcher: folder([user + "/probe"]), + systemWatcher: folder([system + "/admitted"]), + resyncDebounce: { restart() {} }, + checkPluginDirectoryExists() {}, + knownManifests: {}, + pathToPluginId: {}, + directoryModifiedTimes: {}, + loadPluginManifestFile: (path, source) => loaded.push([path, source]) + }); + context.root = context; + for (const name of ["snapshotModel", "resyncAll", "scanPlugins"]) + vm.runInContext(functionSource("../Services/PluginService.qml", name), context); + context.scanPlugins(); + assert.equal(context.userWatcher.folder, disableUserPlugins ? "" : "file://" + user); + assert.equal(context.systemWatcher.folder, "file://" + system); + context.resyncAll(); + const userManifests = disableUserPlugins ? [] : [[user + "/probe/plugin.json", "user"]]; + assert.deepEqual(loaded, [...userManifests, [system + "/admitted/plugin.json", "system"]]); + } +}); + +test("the settings IPC switch refuses set and keeps get", () => { + const source = readFileSync(new URL("../DMSShellIPC.qml", import.meta.url), "utf8"); + const block = source.slice(source.indexOf(" function get(key: string)"), source.indexOf(' target: "settings"')); + const handlers = [...block.matchAll(/^ {8}function \w+\([^)]*\)(?:: \w+)? \{\n[\s\S]*?^ {8}\}$/gm)].map(match => match[0].replace(/\((.*?)\)(?:: \w+)? \{/, (_, params) => "(" + params.replace(/: \w+/g, "") + ") {")); + for (const disableSettingsIpcSet of [false, true]) { + const SettingsData = { lockBeforeSuspend: false, saveSettings() {} }; + const context = vm.createContext({ AdminPolicy: { disableSettingsIpcSet }, SettingsData, log: { warn() {} } }); + for (const handler of handlers) + vm.runInContext(handler, context); + assert.equal(context.set("lockBeforeSuspend", "true"), disableSettingsIpcSet ? "SETTINGS_SET_DISABLED" : "SETTINGS_SET_SUCCESS"); + assert.equal(SettingsData.lockBeforeSuspend, !disableSettingsIpcSet); + assert.equal(context.get("lockBeforeSuspend"), String(!disableSettingsIpcSet)); + } +}); diff --git a/quickshell/tests/lock/mocks/Quickshell/Io/FileView.qml b/quickshell/tests/lock/mocks/Quickshell/Io/FileView.qml index cc3aa322d..0a1d56d66 100644 --- a/quickshell/tests/lock/mocks/Quickshell/Io/FileView.qml +++ b/quickshell/tests/lock/mocks/Quickshell/Io/FileView.qml @@ -4,7 +4,7 @@ Item { property string path property bool printErrors property bool watchChanges - property bool loaded: true + readonly property bool loaded: path !== "" && !MockFiles.missing.includes(path) function reload() { } } diff --git a/quickshell/tests/lock/mocks/Quickshell/Io/MockFiles.qml b/quickshell/tests/lock/mocks/Quickshell/Io/MockFiles.qml new file mode 100644 index 000000000..973cd587c --- /dev/null +++ b/quickshell/tests/lock/mocks/Quickshell/Io/MockFiles.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick + +QtObject { + property var missing: [] +} diff --git a/quickshell/tests/lock/mocks/Quickshell/Io/qmldir b/quickshell/tests/lock/mocks/Quickshell/Io/qmldir index 2e237331f..09fdb2f35 100644 --- a/quickshell/tests/lock/mocks/Quickshell/Io/qmldir +++ b/quickshell/tests/lock/mocks/Quickshell/Io/qmldir @@ -1,3 +1,4 @@ module Quickshell.Io FileView 1.0 FileView.qml +singleton MockFiles 1.0 MockFiles.qml Process 1.0 Process.qml diff --git a/quickshell/tests/lock/mocks/README.md b/quickshell/tests/lock/mocks/README.md index d116678a6..c088ac9e5 100644 --- a/quickshell/tests/lock/mocks/README.md +++ b/quickshell/tests/lock/mocks/README.md @@ -7,5 +7,6 @@ this directory. `PamContext` models start/abort/completion and records attempts. Tests explicitly supply completion results; Qt timers and QML bindings run normally. File and -process doubles do no I/O. These tests cover authentication flow control, not +process doubles do no I/O; a `FileView` is loaded unless its path is empty or +listed in `MockFiles.missing`. These tests cover authentication flow control, not PAM policy enforcement, daemon behavior, or fingerprint hardware. diff --git a/quickshell/tests/lock/mocks/qs/Common/AdminPolicy.qml b/quickshell/tests/lock/mocks/qs/Common/AdminPolicy.qml new file mode 100644 index 000000000..2df176894 --- /dev/null +++ b/quickshell/tests/lock/mocks/qs/Common/AdminPolicy.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick + +QtObject { + property bool disableCustomLockPam: false +} diff --git a/quickshell/tests/lock/mocks/qs/Common/qmldir b/quickshell/tests/lock/mocks/qs/Common/qmldir index 5b896dc34..4de07abec 100644 --- a/quickshell/tests/lock/mocks/qs/Common/qmldir +++ b/quickshell/tests/lock/mocks/qs/Common/qmldir @@ -1,3 +1,4 @@ module qs.Common singleton SettingsData 1.0 SettingsData.qml +singleton AdminPolicy 1.0 AdminPolicy.qml singleton Paths 1.0 Paths.qml diff --git a/quickshell/tests/lock/mocks/qs/Services/Log.qml b/quickshell/tests/lock/mocks/qs/Services/Log.qml new file mode 100644 index 000000000..b38ca8a39 --- /dev/null +++ b/quickshell/tests/lock/mocks/qs/Services/Log.qml @@ -0,0 +1,18 @@ +pragma Singleton +import QtQuick + +QtObject { + id: root + + property var warnings: [] + function scoped(module) { + return { + debug() {}, + info() {}, + warn(...args) { + root.warnings = root.warnings.concat([args.join(" ")]); + }, + error() {} + }; + } +} diff --git a/quickshell/tests/lock/mocks/qs/Services/qmldir b/quickshell/tests/lock/mocks/qs/Services/qmldir index 8589d8aa4..9ba7cc9f3 100644 --- a/quickshell/tests/lock/mocks/qs/Services/qmldir +++ b/quickshell/tests/lock/mocks/qs/Services/qmldir @@ -1,3 +1,4 @@ module qs.Services singleton SessionService 1.0 SessionService.qml singleton IdleService 1.0 IdleService.qml +singleton Log 1.0 Log.qml diff --git a/quickshell/tests/lock/tst_Pam.qml b/quickshell/tests/lock/tst_Pam.qml index 565bba6d3..be2c3752b 100644 --- a/quickshell/tests/lock/tst_Pam.qml +++ b/quickshell/tests/lock/tst_Pam.qml @@ -1,5 +1,6 @@ import QtQuick import QtTest +import Quickshell.Io import Quickshell.Services.Pam import qs.Common import qs.Services @@ -21,8 +22,13 @@ TestCase { SettingsData.lockFingerprintReady = true; SettingsData.lockPamExternallyManaged = false; SettingsData.lockPamPath = ""; + SettingsData.lockU2fPamPath = ""; SettingsData.lockPamInlineFprint = false; + SettingsData.lockPamInlineU2f = false; SettingsData.maxFprintTries = 2; + AdminPolicy.disableCustomLockPam = false; + MockFiles.missing = []; + Log.warnings = []; SessionService.preparingForSleep = false; IdleService.monitorsOff = false; } @@ -73,6 +79,73 @@ TestCase { compare(pam.fprint.status, "disabled"); } + function test_pamStacks_data() { + const policy = { + policy: true, + missing: [], + passwd: "/etc/pam.d/dankshell", + fprint: "/etc/pam.d/dankshell-fprint", + u2f: "/etc/pam.d/dankshell-u2f", + fprintStatus: "active", + secondFactor: true, + warnings: 0 + }; + return [ + { + tag: "settings", + policy: false, + missing: [], + passwd: "/test/custom", + fprint: "/test/assets/pam/fprint", + u2f: "/test/custom-u2f", + fprintStatus: "disabled", + secondFactor: false, + warnings: 0 + }, + Object.assign({}, policy, { + tag: "policy" + }), + Object.assign({}, policy, { + tag: "policyWithoutDankshell", + missing: ["/etc/pam.d/dankshell"], + passwd: "/etc/pam.d/login" + }), + Object.assign({}, policy, { + tag: "policyWithoutU2fStack", + missing: ["/etc/pam.d/dankshell-u2f"], + secondFactor: false, + warnings: 1 + }), + Object.assign({}, policy, { + tag: "policyWithoutFprintStack", + missing: ["/etc/pam.d/dankshell-fprint"], + fprintStatus: "disabled" + }) + ]; + } + + function test_pamStacks(data) { + AdminPolicy.disableCustomLockPam = data.policy; + MockFiles.missing = data.missing; + SettingsData.enableU2f = true; + SettingsData.lockPamPath = "/test/custom"; + SettingsData.lockU2fPamPath = "/test/custom-u2f"; + SettingsData.lockPamInlineFprint = true; + SettingsData.lockPamInlineU2f = true; + SettingsData.lockPamExternallyManaged = true; + const pam = createTemporaryObject(factory, tests); + pam.lockSecured = true; + compare(pam.passwd.configDirectory + "/" + pam.passwd.config, data.passwd); + compare(pam.fprint.configDirectory + "/" + pam.fprint.config, data.fprint); + compare(pam.u2f.configDirectory + "/" + pam.u2f.config, data.u2f); + tryCompare(pam.fprint, "status", data.fprintStatus); + pam.passwd.start(); + pam.passwd.finish(PamResult.Success); + compare(pam.u2fPending, data.secondFactor); + compare(pam.unlockInProgress, !data.secondFactor); + compare(Log.warnings.length, data.warnings); + } + function test_disabledContextIgnoresLateSuccess() { const pam = lockedPam(); SettingsData.enableFprint = false;