Files

269 lines
8.2 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package control
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func private_options(t *testing.T) Options {
t.Helper()
directory, err := os.MkdirTemp("/tmp", "fenrir-remote-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(directory) })
return Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry")}
}
func connect_remote(t *testing.T, path string) *net.UnixConn {
t.Helper()
connection, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
connection.SetDeadline(time.Now().Add(2 * time.Second))
t.Cleanup(func() { connection.Close() })
return connection
}
func TestPythonSocketProtocolAndRegistry(t *testing.T) {
options := private_options(t)
server, err := Open(context.Background(), options)
if err != nil {
t.Fatal(err)
}
defer server.Close()
connection := connect_remote(t, server.main_path())
if _, err := io.WriteString(connection, "command say Existing Bash script\n"); err != nil {
t.Fatal(err)
}
select {
case request := <-server.Requests():
if request.Text != "command say Existing Bash script" || request.UID != os.Geteuid() || request.PID != os.Getpid() {
t.Fatal(request)
}
request.Reply <- ""
case <-time.After(time.Second):
t.Fatal("command was not delivered")
}
for _, command := range []string{"LIST", "command ls"} {
connection := connect_remote(t, server.main_path())
io.WriteString(connection, command+"\n")
response, err := io.ReadAll(connection)
if err != nil || !strings.Contains(string(response), "screen=vcsaDriver keyboard=evdevDriver main_socket=yes") {
t.Fatal(string(response), err)
}
}
info, err := os.Stat(server.main_path())
if err != nil || info.Mode().Perm() != 0666 {
t.Fatal(info, err)
}
instances := server.instances()
if len(instances) != 1 || instances[0].PID != os.Getpid() {
t.Fatal(instances)
}
}
func TestRequestTimeoutReturnsErrorInsteadOfSilentSuccess(t *testing.T) {
server, err := Open(context.Background(), private_options(t))
if err != nil {
t.Fatal(err)
}
defer server.Close()
connection := connect_remote(t, server.main_path())
if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
t.Fatal(err)
}
select {
case <-server.Requests(): // Deliberately leave the admitted operation pending.
case <-time.After(time.Second):
t.Fatal("request not admitted")
}
response, err := io.ReadAll(connection)
if err != nil || !strings.Contains(string(response), "timed out; completion is unknown") {
t.Fatal("timeout looked like success", string(response), err)
}
}
func TestFullRequestQueueReturnsErrorInsteadOfSilentSuccess(t *testing.T) {
server, err := Open(context.Background(), private_options(t))
if err != nil {
t.Fatal(err)
}
defer server.Close()
for range cap(server.requests) {
server.requests <- Request{}
}
connection := connect_remote(t, server.main_path())
if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
t.Fatal(err)
}
response, err := io.ReadAll(connection)
if err != nil || !strings.Contains(string(response), "request queue is full") {
t.Fatal("full queue looked like success", string(response), err)
}
}
func TestCancelledRequestReturnsUnknownCompletion(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server, err := Open(ctx, private_options(t))
if err != nil {
t.Fatal(err)
}
defer server.Close()
connection := connect_remote(t, server.main_path())
if _, err := io.WriteString(connection, "SETTING SAVE\n"); err != nil {
t.Fatal(err)
}
select {
case <-server.Requests():
case <-time.After(time.Second):
t.Fatal("request not admitted")
}
cancel()
response, err := io.ReadAll(connection)
if err != nil || !strings.Contains(string(response), "cancelled; completion is unknown") {
t.Fatal("cancellation looked like success", string(response), err)
}
}
func TestSocketCoexistenceAndCleanupNeverReplaceActiveReader(t *testing.T) {
options := private_options(t)
server, err := Open(context.Background(), options)
if err != nil {
t.Fatal(err)
}
defer server.Close()
if second, err := Open(context.Background(), options); err == nil {
second.Close()
t.Fatal("replaced active socket")
}
// A failed startup must not remove the first reader's registry entry.
if len(server.instances()) != 1 {
t.Fatal("active registry entry removed")
}
if connection, err := net.Dial("unix", server.main_path()); err != nil {
t.Fatal("main socket lost", err)
} else {
connection.Close()
}
}
func TestSocketRejectsSymlinksAndUnrelatedFiles(t *testing.T) {
for _, symlink := range []bool{false, true} {
options := private_options(t)
path := filepath.Join(options.Directory, "fenrirscreenreader-daemon.sock")
if symlink {
if err := os.Symlink("/dev/null", path); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(path, []byte("preserve"), 0600); err != nil {
t.Fatal(err)
}
if server, err := Open(context.Background(), options); err == nil {
server.Close()
t.Fatal("accepted unrelated file")
}
if _, err := os.Lstat(path); err != nil {
t.Fatal("unrelated file removed", err)
}
}
}
func TestSilentAndOversizedClientsDoNotBlockOtherRequests(t *testing.T) {
server, err := Open(context.Background(), private_options(t))
if err != nil {
t.Fatal(err)
}
defer server.Close()
silent := connect_remote(t, server.main_path())
defer silent.Close()
oversized := connect_remote(t, server.main_path())
io.WriteString(oversized, strings.Repeat("x", 9000))
good := connect_remote(t, server.main_path())
io.WriteString(good, "COMMAND INTERRUPT\n")
select {
case request := <-server.Requests():
if request.Text != "COMMAND INTERRUPT" {
t.Fatal(request.Text)
}
request.Reply <- ""
case <-time.After(time.Second):
t.Fatal("silent client blocked reader")
}
select {
case request := <-server.Requests():
t.Fatal("invalid request admitted", request.Text)
case <-time.After(300 * time.Millisecond):
}
}
func TestStaleSocketReplacementAndPrivateInstanceSelection(t *testing.T) {
options := private_options(t)
path := filepath.Join(options.Directory, "stale.sock")
old, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
old.SetUnlinkOnClose(false)
old.Close()
endpoint, err := listen(path)
if err != nil {
t.Fatal(err)
}
endpoint.listener.Close()
if err := os.Mkdir(options.RegistryDirectory, 0700); err != nil {
t.Fatal(err)
}
pid := os.Getppid()
privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", pid))
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: privatePath, Net: "unix"})
if err != nil {
t.Fatal(err)
}
defer listener.Close()
data, err := json.Marshal(instance{PID: pid, ScreenDriver: "ptyDriver", ConnectionHandoff: true, SocketFiles: []string{privatePath}, UpdatedAt: float64(time.Now().UnixNano()) / 1e9})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(options.RegistryDirectory, fmt.Sprintf("%d.json", pid)), data, 0600); err != nil {
t.Fatal(err)
}
options.TerminalMode = true
server := &Server{ctx: context.Background(), options: options}
target, err := server.route_target(pid, os.Geteuid())
if err != nil || target != privatePath {
t.Fatal("registered ancestor instance not selected", target, err)
}
target, err = server.route_target(pid, os.Geteuid()+1)
if err == nil || target != "" {
t.Fatal("forwarded to another user's instance", target, err)
}
}