269 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|