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