315 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|