// 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 . 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) } }