290 lines
9.8 KiB
Go
290 lines
9.8 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package speech
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
)
|
|
|
|
func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T) {
|
|
for _, connectError := range []error{syscall.ENOENT, syscall.ECONNREFUSED} {
|
|
t.Run(connectError.Error(), func(t *testing.T) {
|
|
endpoint := serverAddress{network: "unix", address: "/tmp/selected-speech.sock"}
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
attempts, starts := 0, 0
|
|
got, err := connect_with_autospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) {
|
|
if network != endpoint.network || address != endpoint.address {
|
|
t.Fatal("connection address changed during autostart")
|
|
}
|
|
attempts++
|
|
if attempts == 1 {
|
|
return nil, connectError
|
|
}
|
|
return client, nil
|
|
}, func(_ context.Context, selected serverAddress) error {
|
|
if selected != endpoint {
|
|
t.Fatal("spawn address differs from selected server")
|
|
}
|
|
starts++
|
|
return nil
|
|
})
|
|
if err != nil || got != client || attempts != 2 || starts != 1 {
|
|
t.Fatalf("connection = %v, %v; attempts %d, starts %d", got, err, attempts, starts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
|
|
dial := func(context.Context, string, string) (net.Conn, error) { return nil, syscall.ECONNREFUSED }
|
|
starts := 0
|
|
spawn := func(context.Context, serverAddress) error { starts++; return errors.New("autospawn disabled") }
|
|
_, err := connect_with_autospawn(context.Background(), serverAddress{}, dial, spawn)
|
|
if !errors.Is(err, syscall.ECONNREFUSED) || !strings.Contains(err.Error(), "autospawn disabled") || starts != 1 {
|
|
t.Fatalf("error = %v, starts = %d", err, starts)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, _ = connect_with_autospawn(ctx, serverAddress{}, dial, spawn)
|
|
if starts != 1 {
|
|
t.Fatal("canceled connection started a daemon")
|
|
}
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
_, err = connect_with_autospawn(ctx, serverAddress{}, func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return nil, syscall.ECONNREFUSED
|
|
}, func(context.Context, serverAddress) error { cancel(); return nil })
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("retry error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) {
|
|
for _, test := range []struct {
|
|
endpoint serverAddress
|
|
want []string
|
|
}{
|
|
{serverAddress{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
|
|
{serverAddress{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
|
|
} {
|
|
args, err := spawn_arguments(context.Background(), test.endpoint)
|
|
if err != nil || !reflect.DeepEqual(args, test.want) {
|
|
t.Fatalf("arguments = %v, %v", args, err)
|
|
}
|
|
}
|
|
if _, err := spawn_arguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
|
|
t.Fatal("remote server allowed local autospawn")
|
|
}
|
|
}
|
|
|
|
func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
|
|
t.Setenv("SUDO_UID", "")
|
|
path := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
|
|
if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'autospawn disabled by daemon' >&2\nexit 1\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("SPEECHD_CMD", path)
|
|
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
|
if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) {
|
|
t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher")
|
|
server := new_fake_server(t, false)
|
|
client, err := Open(Options{Socket: server.path})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close()
|
|
}
|
|
|
|
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
|
|
starts := 0
|
|
_, err := connect_with_autospawn(context.Background(), serverAddress{},
|
|
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
|
|
func(context.Context, serverAddress) error { starts++; return nil })
|
|
if !errors.Is(err, syscall.EACCES) || starts != 0 {
|
|
t.Fatalf("error %v, starts %d", err, starts)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryDoesNotSpawn(t *testing.T) {
|
|
marker := filepath.Join(t.TempDir(), "started")
|
|
command := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
|
|
if err := os.WriteFile(command, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("SPEECHD_CMD", command)
|
|
options := Options{Socket: filepath.Join(t.TempDir(), "absent.sock")}
|
|
if _, err := Discover(options); err == nil {
|
|
t.Fatal("expected connection failure")
|
|
}
|
|
if _, err := discover_modules(options); err == nil {
|
|
t.Fatal("expected connection failure")
|
|
}
|
|
if _, err := discover_voices(options); err == nil {
|
|
t.Fatal("expected connection failure")
|
|
}
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("discovery started a daemon: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnprivilegedProcessDoesNotImpersonateSudoUser(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("requires an unprivileged caller")
|
|
}
|
|
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
|
|
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
|
if err == nil || !strings.Contains(err.Error(), "invoking user") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
// Run against a separate dummy-output daemon, never the user's speech server.
|
|
func TestNativeSpeechDispatcherColdStart(t *testing.T) {
|
|
if os.Getenv("FENRIR_NATIVE_SPEECH_STARTUP") != "1" {
|
|
t.Skip("requires isolated Speech Dispatcher startup")
|
|
}
|
|
if _, err := exec.LookPath("speech-dispatcher"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, staleSocket := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("stale-socket-%v", staleSocket), func(t *testing.T) {
|
|
directory, err := os.MkdirTemp("", "speech-start-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(directory)
|
|
configPath := filepath.Join(directory, "speechd.conf")
|
|
config := "LogLevel 0\nLogDir \"" + directory + "\"\nAddModule \"dummy\" \"sd_dummy\" \"\"\nDefaultModule dummy\nTimeout 1\n"
|
|
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pidPath := filepath.Join(directory, "speechd.pid")
|
|
wrapper := filepath.Join(directory, "spawn")
|
|
script := "#!/bin/sh\nexec speech-dispatcher --config-dir \"$FENRIR_TEST_SPEECH_CONFIG\" --pid-file \"$FENRIR_TEST_SPEECH_PID\" \"$@\"\n"
|
|
if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("FENRIR_TEST_SPEECH_CONFIG", directory)
|
|
t.Setenv("FENRIR_TEST_SPEECH_PID", pidPath)
|
|
t.Setenv("SPEECHD_CMD", wrapper)
|
|
t.Setenv("XDG_RUNTIME_DIR", directory)
|
|
t.Setenv("SPEECHD_ADDRESS", "")
|
|
t.Setenv("SPEECHD_SOCKET", "")
|
|
t.Setenv("SUDO_UID", "")
|
|
defer func() {
|
|
if data, err := os.ReadFile(pidPath); err == nil {
|
|
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
|
}
|
|
}
|
|
}()
|
|
socket := default_socket_path()
|
|
if staleSocket {
|
|
if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listener.SetUnlinkOnClose(false)
|
|
listener.Close()
|
|
}
|
|
|
|
// Selection preparation must also start a cold daemon before querying
|
|
// membership, even when the saved module is no longer installed.
|
|
if staleSocket {
|
|
candidate, prepareErr := Prepare("speech-dispatcher", Options{Module: "unavailable"})
|
|
if candidate != nil {
|
|
candidate.Close()
|
|
t.Fatal("unexpected candidate")
|
|
}
|
|
if prepareErr == nil || !strings.Contains(prepareErr.Error(), `speech module "unavailable" is unavailable`) {
|
|
t.Fatalf("selection error = %v", prepareErr)
|
|
}
|
|
}
|
|
client, err := Open(Options{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close()
|
|
if client.id == "" || client.control == nil {
|
|
t.Fatal("cold-start client did not initialize both channels")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSystemSocketFallback(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
userError error
|
|
systemError error
|
|
wantSystem bool
|
|
}{
|
|
{"user first", nil, nil, false},
|
|
{"missing user", syscall.ENOENT, nil, true},
|
|
{"stale user", syscall.ECONNREFUSED, nil, true},
|
|
{"permission denied", syscall.EACCES, nil, false},
|
|
{"both absent", syscall.ENOENT, syscall.ENOENT, true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
var addresses []string
|
|
dial := func(_ context.Context, network, address string) (net.Conn, error) {
|
|
addresses = append(addresses, address)
|
|
if network != "unix" {
|
|
t.Fatalf("network = %s", network)
|
|
}
|
|
err := test.userError
|
|
if address == "/run/speech-dispatcher/speechd.sock" {
|
|
err = test.systemError
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
got, err := dial_with_system_fallback(dial)(context.Background(), "unix", "/user/speechd.sock")
|
|
want := []string{"/user/speechd.sock"}
|
|
if test.wantSystem {
|
|
want = append(want, "/run/speech-dispatcher/speechd.sock")
|
|
}
|
|
if !reflect.DeepEqual(addresses, want) {
|
|
t.Fatalf("dialed %v, want %v", addresses, want)
|
|
}
|
|
if test.userError == nil || test.wantSystem && test.systemError == nil {
|
|
if err != nil || got != client {
|
|
t.Fatalf("connection = %v, %v", got, err)
|
|
}
|
|
} else if !errors.Is(err, test.userError) {
|
|
t.Fatalf("error = %v, want %v", err, test.userError)
|
|
}
|
|
})
|
|
}
|
|
}
|