Add opt-in administrator lock restrictions (#3869)

(cherry picked from commit 7781c207f1)
This commit is contained in:
alvarosth
2026-10-22 14:57:52 -04:00
committed by bbedward
parent 2a636c7092
commit 430db4e493
24 changed files with 490 additions and 50 deletions
+7
View File
@@ -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) {
+78 -13
View File
@@ -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_<KEY> 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_<KEY>=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
}
+101
View File
@@ -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)
}
+3 -1
View File
@@ -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)
+16 -7
View File
@@ -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()
+30 -14
View File
@@ -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{
+1
View File
@@ -69,4 +69,5 @@ type Manager struct {
lockTimer *time.Timer
sleepInhibitorEnabled atomic.Bool
fallbackDelay time.Duration
keepLockOnUnlock bool
}
+8 -1
View File
@@ -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
+12
View File
@@ -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"
}
+2
View File
@@ -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";
+8
View File
@@ -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();
}
+32 -8
View File
@@ -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();
}
+4 -1
View File
@@ -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");
}
}
+5 -3
View File
@@ -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();
+74
View File
@@ -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));
}
});
@@ -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() {
}
}
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
property var missing: []
}
@@ -1,3 +1,4 @@
module Quickshell.Io
FileView 1.0 FileView.qml
singleton MockFiles 1.0 MockFiles.qml
Process 1.0 Process.qml
+2 -1
View File
@@ -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.
@@ -0,0 +1,6 @@
pragma Singleton
import QtQuick
QtObject {
property bool disableCustomLockPam: false
}
@@ -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
@@ -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() {}
};
}
}
@@ -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
+73
View File
@@ -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;