Files

315 lines
8.7 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 speech
import (
"bufio"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestSpeechAcknowledgementWaitsForBusyDispatcher(t *testing.T) {
server := new_fake_server(t, true)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
result := make(chan error, 1)
go func() { result <- client.Say("startup") }()
wait_event(t, server.events, "say:startup")
// Voice catalog requests can occupy the dispatcher for more than 750 ms.
time.Sleep(time.Second)
close(server.ack)
if err := <-result; err != nil {
t.Fatalf("speech failed while dispatcher was busy: %v", err)
}
}
type fakeServer struct {
path string
events chan string
ack chan struct{}
stop chan struct{}
mu sync.Mutex
connections []net.Conn
settings []string
voices []string
reject string
nextID int
wg sync.WaitGroup
}
func new_fake_server(t *testing.T, block bool) *fakeServer {
t.Helper()
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
if err != nil {
t.Fatal(err)
}
return new_fake_server_listener(t, block, listener)
}
func new_fake_server_listener(t *testing.T, block bool, listener net.Listener) *fakeServer {
t.Helper()
server := &fakeServer{path: listener.Addr().String(), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
if !block {
close(server.ack)
}
server.wg.Add(1)
acceptedDone := make(chan struct{})
go func() {
defer server.wg.Done()
defer close(acceptedDone)
for {
conn, err := listener.Accept()
if err != nil {
return
}
server.mu.Lock()
server.connections = append(server.connections, conn)
server.mu.Unlock()
server.wg.Add(1)
go func() { defer server.wg.Done(); defer conn.Close(); server.serve(conn) }()
}
}()
t.Cleanup(func() {
close(server.stop)
listener.Close()
<-acceptedDone
// Wait for Accept to finish before taking the connection list.
server.mu.Lock()
for _, conn := range server.connections {
conn.Close()
}
server.mu.Unlock()
server.wg.Wait()
})
return server
}
func (s *fakeServer) serve(conn net.Conn) {
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
switch {
case strings.HasPrefix(line, "SET "):
s.mu.Lock()
s.settings = append(s.settings, line)
reject := s.reject != "" && strings.HasPrefix(line, s.reject)
s.mu.Unlock()
if reject {
fmt.Fprint(conn, "500 REJECTED\r\n")
} else {
fmt.Fprint(conn, "200 OK\r\n")
}
case line == "LIST OUTPUT_MODULES":
fmt.Fprint(conn, "200-fixture\r\n200-other\r\n200 OK\r\n")
case line == "LIST SYNTHESIS_VOICES":
s.mu.Lock()
voices := append([]string(nil), s.voices...)
s.mu.Unlock()
if voices == nil {
voices = []string{"Base Voice\ten-US\tnone", "Base Voice+Variant\ten-US\tVariant", "German\tde\tnone"}
}
for _, voice := range voices {
fmt.Fprintf(conn, "249-%s\r\n", voice)
}
fmt.Fprint(conn, "249 OK VOICES SENT\r\n")
case line == "HISTORY GET CLIENT_ID":
s.mu.Lock()
s.nextID++
id := 40 + s.nextID
s.mu.Unlock()
fmt.Fprintf(conn, "200-%d\r\n200 OK CLIENT ID SENT\r\n", id)
case strings.HasPrefix(line, "CHAR "):
s.events <- "char:" + strings.TrimPrefix(line, "CHAR ")
fmt.Fprint(conn, "200 OK\r\n")
case line == "SPEAK":
fmt.Fprint(conn, "230 OK RECEIVING DATA\r\n")
var lines []string
for {
text, err := reader.ReadString('\n')
if err != nil {
return
}
text = strings.TrimSuffix(strings.TrimSuffix(text, "\n"), "\r")
if text == "." {
break
}
if strings.HasPrefix(text, "..") {
text = text[1:]
}
lines = append(lines, text)
}
s.events <- "say:" + strings.Join(lines, "\n")
select {
case <-s.ack:
case <-s.stop:
return
}
fmt.Fprint(conn, "225-17\r\n225 OK MESSAGE QUEUED\r\n")
case strings.HasPrefix(line, "CANCEL "):
s.events <- line
fmt.Fprint(conn, "200 OK CANCELED\r\n")
default:
s.events <- "unexpected:" + line
fmt.Fprint(conn, "500 INVALID COMMAND\r\n")
}
}
}
func TestSSIPDotStuffingAndTargetedCancellation(t *testing.T) {
server := new_fake_server(t, false)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
text := ".\nCANCEL ALL\n..literal\né"
if err := client.Say(text); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "say:"+text)
if err := client.Cancel(); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "CANCEL 41")
}
func TestSingleCharactersUseOneCommandWithoutSpeakHandshake(t *testing.T) {
server := new_fake_server(t, false)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
for _, text := range []string{"a", "é", "界", ".", "\\", "\""} {
if err := client.Say(text); err != nil {
t.Fatal(err)
}
select {
case got := <-server.events:
if got != "char:"+text {
t.Fatalf("single character used extra speech transaction: %q", got)
}
default:
t.Fatal("character acknowledgement did not follow its command")
}
}
for _, text := range []string{"capital E", "space", "\nCANCEL ALL", "\x00"} {
if err := client.Say(text); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "say:"+strings.ReplaceAll(text, "\x00", ""))
}
}
func TestSSIPControlWorksWhileSpeakAcknowledgementIsBlocked(t *testing.T) {
server := new_fake_server(t, true)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
result := make(chan error, 1)
go func() { result <- client.Say("blocked") }()
wait_event(t, server.events, "say:blocked")
if err := client.Cancel(); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "CANCEL 41")
close(server.ack)
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestSpeechOptionsRejectCommandInjection(t *testing.T) {
for _, options := range []Options{{Rate: 101}, {Rate: -101}, {Module: "voice\r\nCANCEL ALL"}} {
if _, err := Open(options); err == nil {
t.Fatalf("accepted unsafe options: %+v", options)
}
}
}
func TestLiveParametersUseSpeakingClientAndRejectInvalidValues(t *testing.T) {
server := new_fake_server(t, false)
pitch, volume := -20, 90
client, err := Open(Options{Socket: server.path, Rate: 80, Pitch: &pitch, Volume: &volume})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if err := client.Configure(Parameters{Rate: 90, Pitch: 10, Volume: 80}); err != nil {
t.Fatal(err)
}
server.mu.Lock()
commands := append([]string(nil), server.settings...)
server.mu.Unlock()
for _, expected := range []string{"SET SELF RATE 80", "SET SELF PITCH -20", "SET SELF VOLUME 90", "SET SELF RATE 90", "SET SELF PITCH 10", "SET SELF VOLUME 80"} {
found := false
for _, got := range commands {
if got == expected {
found = true
}
}
if !found {
t.Fatal(expected, commands)
}
}
for _, invalid := range []Parameters{{Rate: 101}, {Pitch: -101}, {Volume: 101}} {
if err := client.Configure(invalid); err == nil {
t.Fatal(invalid)
}
}
if err := client.Say("feedback"); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "say:feedback")
if err := client.Cancel(); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "CANCEL 41")
}
func TestDefaultSocketForSudo(t *testing.T) {
t.Setenv("SPEECHD_ADDRESS", "")
t.Setenv("SPEECHD_SOCKET", "")
runtimeDirectory := t.TempDir()
t.Setenv("SUDO_UID", strconv.Itoa(os.Getuid()))
t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory)
if got, err := resolve_server_address(""); err != nil || got.address != filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock") {
t.Fatal(got, err)
}
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
t.Fatal(got, err)
}
}