The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs 0.4's structure. It offers only the session scripts that modules place in /etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which skips the session's start. The service is enabled and never started, stopped or restarted by a push. The tools are the seat's sessions, the default session (lemurs's cache, through sudo -n) and logins from the journal and lemurs's own log. The package swap from lemurs-git is a one-off step for the operator, listed in the README.
256 lines
9.3 KiB
Go
256 lines
9.3 KiB
Go
package desktop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
|
|
type fakeMachine struct {
|
|
t *testing.T
|
|
proc, x11, runtime string
|
|
uid int
|
|
}
|
|
|
|
func newMachine(t *testing.T) *fakeMachine {
|
|
root, err := os.MkdirTemp("", "desk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(root) })
|
|
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
|
|
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *fakeMachine) socket(path string) {
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
m.t.Fatal(err)
|
|
}
|
|
m.t.Cleanup(func() { l.Close() })
|
|
}
|
|
|
|
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
|
|
dir := filepath.Join(m.proc, strconv.Itoa(pid))
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
m.t.Fatal(err)
|
|
}
|
|
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
|
|
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
|
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
|
|
// cutime cstime priority nice threads itrealvalue starttime ...
|
|
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
|
|
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
|
|
}
|
|
|
|
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
|
|
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
|
|
}
|
|
|
|
func active(id string) (map[string]string, error) {
|
|
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
|
|
}
|
|
|
|
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X1"))
|
|
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
|
m.socket(filepath.Join(run, "bus"))
|
|
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
|
|
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
|
|
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
|
|
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
|
|
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
|
|
|
|
s, err := m.finder(active, "i3").Find()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
|
|
t.Fatalf("session: %+v", s)
|
|
}
|
|
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
|
|
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
|
|
}
|
|
if s.Active == nil || !*s.Active {
|
|
t.Fatal("logind's word is carried")
|
|
}
|
|
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
|
|
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
|
|
if !strings.Contains(env, want) {
|
|
t.Errorf("env lacks %s:\n%s", want, env)
|
|
}
|
|
}
|
|
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
|
|
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
|
|
}
|
|
b, _ := json.Marshal(s)
|
|
if strings.Contains(string(b), "secret") {
|
|
t.Fatal("the answer carries a word outside the session's")
|
|
}
|
|
}
|
|
|
|
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X0"))
|
|
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
|
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
|
s, err := m.finder(nil).Find()
|
|
if err != nil || s.FoundIn.PID != 400 {
|
|
t.Fatalf("%+v %v", s, err)
|
|
}
|
|
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
|
|
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
|
|
_, err := m.finder(active, "i3").Find()
|
|
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
var answer map[string]any
|
|
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
|
|
t.Fatalf("the refusal is structured: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
|
|
_, err := m.finder(active).Find()
|
|
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X0"))
|
|
m.socket(filepath.Join(m.x11, "X1"))
|
|
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
|
|
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
|
|
logind := func(id string) (map[string]string, error) {
|
|
if id == "a" {
|
|
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
|
|
}
|
|
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
|
|
}
|
|
s, err := m.finder(logind, "i3").Find()
|
|
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
|
|
t.Fatalf("the active session: %+v %v", s, err)
|
|
}
|
|
remote := func(string) (map[string]string, error) {
|
|
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
|
|
}
|
|
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
|
|
t.Fatalf("a remote session is not the operator's desktop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
|
|
m := newMachine(t)
|
|
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
|
m.socket(filepath.Join(run, "wayland-1"))
|
|
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
|
|
s, err := m.finder(nil, "sway").Find()
|
|
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
|
|
t.Fatalf("%+v %v", s, err)
|
|
}
|
|
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
|
|
t.Fatal("the compositor's socket word passes")
|
|
}
|
|
}
|
|
|
|
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
|
|
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
|
|
if _, ok := DisplayNumber(d); ok != want {
|
|
t.Errorf("%q: %v", d, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
|
|
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
|
|
if !r.OK() || r.Stdout != "hello" {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
|
|
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
|
|
if r.OK() || r.Code != 127 {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
|
|
if !r.Truncated || len(r.Stdout) != MostOutput {
|
|
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
|
|
}
|
|
}
|
|
|
|
func TestArgumentsAreReadStrictly(t *testing.T) {
|
|
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
|
if v, err := a.Text("name"); err != nil || v != "x" {
|
|
t.Fatal(v, err)
|
|
}
|
|
if _, err := a.Text("missing"); err == nil {
|
|
t.Fatal("a missing required text")
|
|
}
|
|
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
|
|
t.Fatal(n, err)
|
|
}
|
|
if _, err := a.Whole("f", 0, 0, 5); err == nil {
|
|
t.Fatal("1.5 is not whole")
|
|
}
|
|
if _, err := a.Whole("n", 0, 4, 5); err == nil {
|
|
t.Fatal("out of range")
|
|
}
|
|
if b, given, err := a.Bool("b"); !b || !given || err != nil {
|
|
t.Fatal("bool")
|
|
}
|
|
if _, _, err := a.Bool("name"); err == nil {
|
|
t.Fatal("text is not a bool")
|
|
}
|
|
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
|
|
t.Fatal(l, err)
|
|
}
|
|
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
|
|
t.Fatal("not one of")
|
|
}
|
|
}
|
|
|
|
func TestAPathIsKeptInsideTheHome(t *testing.T) {
|
|
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
|
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
|
|
if got, err := InHome(in); err != nil || got != want {
|
|
t.Errorf("%s: %s %v", in, got, err)
|
|
}
|
|
}
|
|
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
|
|
if _, err := InHome(out); err == nil {
|
|
t.Errorf("%s was accepted", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPropertiesAreParsed(t *testing.T) {
|
|
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
|
|
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
|
|
t.Fatal(p)
|
|
}
|
|
}
|