// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "fmt" "log/slog" "os" "os/exec" "path/filepath" "reflect" "testing" "time" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "github.com/godbus/dbus/v5" ) func TestScreenLockRejectsLocalOutputButPreservesRemoteFeedback(t *testing.T) { logger := slog.New(slog.DiscardHandler) worker := newSpeechWorker(logger, nil) worker.running = true // Retain requests for inspection without backend I/O. worker.EnqueueStartup(speechRequest{Text: "Welcome", Cause: "startup"}) display := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)} display.EnqueueFrame(braille.Frame{Text: "Desktop", Cursor: 0}) display.Flash("Notice", config.BrailleOptions{FlashMessages: true, FlashPersistent: true}) commands := &commandGrabRecorder{} gate := newFocusGate(commands) gate.speech = worker gate.SetLocked(true, nil, nil, commands, worker, display) if len(worker.pending) != 0 || !worker.startupUntil.IsZero() || display.current().Text != "" || display.baseFrame.Text != "" { t.Fatal("lock retained desktop presentation or startup protection") } // A terminal focus transition must never reopen command grabs during lock. gate.SetSuspended(true) gate.SetSuspended(false) for _, enabled := range commands.grabs() { if enabled { t.Fatal("terminal focus restored grabs while locked") } } guarded := gatedSpeech{speech: worker, gate: gate} guarded.Enqueue(speechRequest{Text: "Private desktop", BypassFocusGate: true}) presentNotifications(guarded, []string{"Notification ready"}) worker.Enqueue(speechRequest{Text: "Notification", Cause: "notification"}) if guarded.InterruptForInput(input.Event{Pressed: true}) { t.Fatal("password key interrupted locker feedback") } display.Enqueue("Private desktop") display.EnqueueMessage("Notification") if len(worker.pending) != 0 || display.current().Text != "" { t.Fatal("local output escaped secure mode") } remote := remoteSpeech{speech: worker, braille: lockFeedbackBraille{display}} if !remote.PresentMessage("Screen locked") || len(worker.pending) != 1 || worker.pending[0].Text != "Screen locked" || display.current().Text != "Screen locked" { t.Fatal("I38lock feedback was suppressed") } gate.SetLocked(false, nil, nil, commands, worker, display) if display.current().Text != "" { t.Fatal("unlock retained locker feedback") } guarded.Enqueue(speechRequest{Text: "Desktop resumed"}) if len(worker.pending) != 2 || !commands.grabs()[len(commands.grabs())-1] { t.Fatal("unlock did not resume desktop presentation and grabs") } } func TestScreenLockOwnerAuthenticationAndReplacement(t *testing.T) { var states []bool monitor := &screenLockMonitor{logger: slog.New(slog.DiscardHandler), setActive: func(active bool) { states = append(states, active) }, trustedOwner: func(_ context.Context, owner string) bool { return owner == ":1.42" }} for _, owner := range []string{":1.41", ":1.42", ":1.42", "", ":1.43"} { monitor.updateOwner(context.Background(), owner) } if !reflect.DeepEqual(states, []bool{true, false}) { t.Fatalf("lock transitions = %v", states) } } // Executed under a copied test executable named i38lock, so /proc/PID/exe // exercises the production owner authentication rather than a mocked PID. func TestI38lockOwnerHelper(t *testing.T) { if os.Getenv("CTHULHU_LOCK_HELPER") != "1" { t.Skip("subprocess helper") } connection, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer connection.Close() if reply, err := connection.RequestName(screenLockBusName, dbus.NameFlagDoNotQueue); err != nil || reply != dbus.RequestNameReplyPrimaryOwner { t.Fatalf("request lock name: %v, %v", reply, err) } fmt.Println("ready") var input [1]byte _, _ = os.Stdin.Read(input[:]) _, _ = connection.ReleaseName(screenLockBusName) } func TestNativeI38lockOwnerStartupUnlockAndSpoofing(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { t.Skip("requires private session bus") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() executable, err := os.Executable() if err != nil { t.Fatal(err) } data, err := os.ReadFile(executable) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "i38lock") if err := os.WriteFile(path, data, 0700); err != nil { t.Fatal(err) } process := exec.CommandContext(ctx, path, "-test.run=^TestI38lockOwnerHelper$") process.Env = append(os.Environ(), "CTHULHU_LOCK_HELPER=1") stdout, err := process.StdoutPipe() if err != nil { t.Fatal(err) } stdin, err := process.StdinPipe() if err != nil { t.Fatal(err) } process.Stderr = os.Stderr if err := process.Start(); err != nil { t.Fatal(err) } defer process.Process.Kill() line, err := bufio.NewReader(stdout).ReadString('\n') if err != nil || line != "ready\n" { t.Fatalf("helper ready = %q, %v", line, err) } states := make(chan bool, 8) monitor := &screenLockMonitor{logger: slog.New(slog.DiscardHandler), setActive: func(active bool) { states <- active }} if err := monitor.Start(ctx); err != nil { t.Fatal(err) } defer monitor.Stop(context.Background()) expect := func(want bool) { t.Helper() select { case got := <-states: if got != want { t.Fatalf("secure state = %v, want %v", got, want) } case <-ctx.Done(): t.Fatal("missing secure state transition") } } expect(true) // Reader started after the screen was already locked. stdin.Close() if err := process.Wait(); err != nil { t.Fatal(err) } expect(false) spoof, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer spoof.Close() if _, err := spoof.RequestName(screenLockBusName, dbus.NameFlagDoNotQueue); err != nil { t.Fatal(err) } select { case <-states: t.Fatal("untrusted owner changed secure state") case <-time.After(100 * time.Millisecond): } _, _ = spoof.ReleaseName(screenLockBusName) // Verify a trusted owner appearing after subscription as well as startup. process = exec.CommandContext(ctx, path, "-test.run=^TestI38lockOwnerHelper$") process.Env = append(os.Environ(), "CTHULHU_LOCK_HELPER=1") stdout, err = process.StdoutPipe() if err != nil { t.Fatal(err) } stdin, err = process.StdinPipe() if err != nil { t.Fatal(err) } if err := process.Start(); err != nil { t.Fatal(err) } defer process.Process.Kill() line, err = bufio.NewReader(stdout).ReadString('\n') if err != nil || line != "ready\n" { t.Fatalf("helper ready = %q, %v", line, err) } expect(true) stdin.Close() if err := process.Wait(); err != nil { t.Fatal(err) } expect(false) }