Reset master to testing content

This commit is contained in:
Storm Dragon committed 2026-10-25 01:57:54 -04:00
1 parent 0f2587b719
commit 42afa2a56c
1483 files changed
+145308 -938069

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"bytes"
"encoding/json"
"unicode"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/interaction"
)
// Interaction is an opt-in command result. The host runs forms outside the
// bounded RPC deadline and invokes Next with the submitted values. Poll is a
// registered command sampled once per second while the form remains open.
// A polling reply may announce a changed status or finish the form with Done.
type Interaction struct {
Presentation
Form *interaction.Form `json:"form,omitempty"`
Next string `json:"next,omitempty"`
Poll string `json:"poll,omitempty"`
Source string `json:"source,omitempty"` // "focus" or "clipboard", supplied to Next.
Clipboard *string `json:"clipboard,omitempty"`
Done bool `json:"done,omitempty"`
Reader *ReaderRequest `json:"reader,omitempty"`
}
type Submission struct {
Activated string `json:"activated,omitempty"`
Values map[string]interaction.Value `json:"values,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
Item *Item `json:"item,omitempty"`
Error string `json:"error,omitempty"`
Reader *ReaderState `json:"reader,omitempty"`
}
// Item is a single copied value, without accessible identities or native handles.
type Item struct {
Temporary bool `json:"-"` // Host-only ownership of materialized clipboard data.
Text string `json:"text,omitempty"`
Path string `json:"path,omitempty"`
}
func DecodeInteraction(data []byte) (Interaction, error) {
var value Interaction
var fields map[string]json.RawMessage
if json.Unmarshal(data, &fields) != nil || fields == nil {
return value, ErrProtocol
}
for _, field := range fields {
if bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
return value, ErrProtocol
}
}
if err := DecodeStrict(data, &value); err != nil {
return value, err
}
if value.Speech != "" || value.Braille != "" {
message, _ := json.Marshal(value.Presentation)
if _, err := DecodePresentation(message); err != nil {
return value, err
}
} else if value.Uninterruptible {
return value, ErrProtocol
}
if value.Clipboard != nil && (!utf8.ValidString(*value.Clipboard) || len(*value.Clipboard) > MaxPresentationBytes) {
return value, ErrLimit
}
if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" {
return value, ErrProtocol
}
if value.Reader != nil {
if ValidateReaderRequest(*value.Reader) != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done || value.Reader.Snapshot && value.Next == "" {
return value, ErrProtocol
}
} else if value.Form != nil {
for _, element := range value.Form.Elements {
if !validFormText(element.Label) || !validFormText(element.ID) {
return value, ErrProtocol
}
for _, item := range element.Items {
if !validFormText(item.Label) || !validFormText(item.ID) {
return value, ErrProtocol
}
}
}
if !validFormText(value.Form.Title) {
return value, ErrProtocol
}
if _, err := interaction.NewSession(*value.Form); err != nil {
return value, ErrProtocol
}
if value.Next == "" || value.Done || value.Source != "" {
return value, ErrProtocol
}
} else if value.Poll != "" || value.Next != "" && value.Source == "" || value.Source != "" && value.Next == "" {
return value, ErrProtocol
}
return value, nil
}
func validFormText(text string) bool {
if !utf8.ValidString(text) {
return false
}
for _, r := range text {
if unicode.IsControl(r) {
return false
}
}
return true
}
+57
View File
@@ -0,0 +1,57 @@
package tentaclewire
import (
"encoding/json"
"git.stormux.org/storm/cthulhu/internal/interaction"
"strings"
"testing"
)
func TestInteractionContract(t *testing.T) {
form := &interaction.Form{Title: "Wormhole menu", Elements: []interaction.Element{{ID: "choices", Label: "Options", Kind: interaction.List, Items: []interaction.Item{{ID: "send", Label: "Send focused item"}}}}}
for _, value := range []Interaction{
{Form: form, Next: "choice"}, {Form: form, Next: "choice", Poll: "status"}, {Source: "focus", Next: "send"}, {Source: "clipboard", Next: "send"},
{Presentation: Presentation{Speech: "Ready"}, Done: true}, {Done: true}, {},
} {
data, _ := json.Marshal(value)
if _, err := DecodeInteraction(data); err != nil {
t.Fatalf("%s: %v", data, err)
}
}
for _, raw := range []string{`null`, ` null `, `{"form":null}`, `{"clipboard":null}`, `{"unknown":true}`, `{"source":"desktop","next":"send"}`, `{"source":"focus"}`, `{"next":"send"}`, `{"poll":"status"}`, `{"speech":"bad\u0000"}`, `{"uninterruptible":true}`} {
if _, err := DecodeInteraction([]byte(raw)); err == nil {
t.Fatal("accepted", raw)
}
}
for _, value := range []Interaction{{Form: form}, {Form: form, Next: "choice", Done: true}, {Form: form, Next: "choice", Source: "focus"}} {
data, _ := json.Marshal(value)
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted", string(data))
}
}
copy := strings.Repeat("x", MaxPresentationBytes+1)
data, _ := json.Marshal(Interaction{Clipboard: &copy})
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted oversized clipboard write")
}
form.Title = "Bad\x1btitle"
data, _ = json.Marshal(Interaction{Form: form, Next: "choice"})
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted control in title")
}
}
func TestInteractionGrantsRemainExplicit(t *testing.T) {
for _, apis := range [][]string{{"commands"}, {"commands", "presentation"}, {"commands", "presentation", "interaction"}} {
if !ValidAPIs(apis, apis) {
t.Fatal("rejected", apis)
}
}
for _, apis := range [][]string{{"commands", "interaction"}, {"commands", "presentation", "unknown"}, {"commands", "interaction", "presentation"}} {
if ValidAPIs(apis, apis) {
t.Fatal("accepted", apis)
}
}
if ValidAPIs([]string{"commands", "presentation", "interaction"}, []string{"commands", "presentation"}) {
t.Fatal("missing grant accepted")
}
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"bytes"
"encoding/json"
"unicode"
"unicode/utf8"
)
const MaxPresentationBytes = 8192 // Per channel, measured in UTF-8 bytes.
// Presentation is a command result, never an unsolicited announcement. Empty
// channels are omitted; neither channel supplies positions or routing targets.
type Presentation struct {
Speech string `json:"speech,omitempty"`
Braille string `json:"braille,omitempty"`
Uninterruptible bool `json:"uninterruptible,omitempty"`
}
func DecodePresentation(data []byte) (Presentation, error) {
var value Presentation
if err := DecodeStrict(data, &value); err != nil {
return value, err
}
var fields map[string]json.RawMessage
if json.Unmarshal(data, &fields) != nil || fields == nil || len(fields) == 0 {
return value, ErrProtocol
}
for _, field := range fields {
if bytes.Equal(field, []byte("null")) {
return value, ErrProtocol
}
}
if value.Speech == "" && value.Braille == "" {
return value, ErrProtocol
}
if value.Uninterruptible && value.Speech == "" {
return value, ErrProtocol
}
for _, text := range []string{value.Speech, value.Braille} {
if len(text) > MaxPresentationBytes {
return value, ErrLimit
}
if !utf8.ValidString(text) {
return value, ErrProtocol
}
for _, char := range text {
if unicode.IsControl(char) && char != '\n' && char != '\t' {
return value, ErrProtocol
}
}
}
return value, nil
}
// The experimental optional API is additive to the command-only handshake.
func ValidAPIs(apis, capabilities []string) bool {
if len(apis) != len(capabilities) || len(apis) < 1 || len(apis) > 4 || apis[0] != "commands" {
return false
}
if len(apis) >= 2 && apis[1] != "presentation" || len(apis) >= 3 && apis[2] != "interaction" || len(apis) == 4 && apis[3] != "reader-settings" {
return false
}
for index := range apis {
if apis[index] != capabilities[index] {
return false
}
}
return true
}
@@ -0,0 +1,37 @@
package tentaclewire
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestPresentationContract(t *testing.T) {
for _, value := range []Presentation{
{Speech: "Hello, é猫😀é"}, {Braille: "é猫😀é"},
{Speech: "Speech text", Braille: "Display text"},
{Speech: "Hello world!", Braille: "Hello world!", Uninterruptible: true},
{Speech: strings.Repeat("é", MaxPresentationBytes/2)},
} {
data, _ := json.Marshal(value)
decoded, err := DecodePresentation(data)
if err != nil || decoded != value {
t.Fatalf("round trip: %+v, %v", decoded, err)
}
}
for _, data := range []string{`null`, `[]`, `{}`, `{"speech":null}`, `{"speech":42}`,
`{"speech":""}`, `{"speech":"hello","voice":"default"}`, `{"speech":"one","speech":"two"}`,
`{"braille":"bad\u0000text"}`, `{"speech":"bad\u001btext"}`,
`{"braille":"hello","uninterruptible":true}`, `{"uninterruptible":true}`,
`{"speech":"hello","uninterruptible":null}`, `{"speech":"hello","uninterruptible":"true"}`,
`{"speech":"hello","uninterruptible":true,"uninterruptible":false}`} {
if _, err := DecodePresentation([]byte(data)); !errors.Is(err, ErrProtocol) {
t.Fatalf("accepted %s: %v", data, err)
}
}
data, _ := json.Marshal(Presentation{Braille: strings.Repeat("é", MaxPresentationBytes/2+1)})
if _, err := DecodePresentation(data); !errors.Is(err, ErrLimit) {
t.Fatal("UTF-8 byte bound", err)
}
}
+247
View File
@@ -0,0 +1,247 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"errors"
"strings"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/speech"
)
// SpeechConfiguration is a complete voice configuration, never arbitrary reader
// state. Enablement, interruption policy, input and unrelated preferences remain
// under the core's control. Nil voice parameters retain server/inherited defaults.
type SpeechConfiguration struct {
Server string `json:"server"`
Module string `json:"module"`
Device string `json:"device,omitempty"`
Baud int `json:"baud,omitempty"`
DefaultVoice config.Voice `json:"default_voice"`
UppercaseVoice config.Voice `json:"uppercase_voice"`
HyperlinkVoice config.Voice `json:"hyperlink_voice"`
SystemVoice config.Voice `json:"system_voice"`
RaisePitchForCapitals bool `json:"raise_pitch_for_capitals"`
BeepForCapitals bool `json:"beep_for_capitals"`
SayCapForCapitals bool `json:"say_cap_for_capitals"`
Punctuation int `json:"punctuation"`
CustomEcho bool `json:"custom_echo"`
CustomEchoServer bool `json:"custom_echo_server"`
EchoServer string `json:"echo_server,omitempty"`
EchoVoice config.Voice `json:"echo_voice"`
EchoForKey bool `json:"echo_for_key"`
EchoForCharacter bool `json:"echo_for_character"`
EchoForWord bool `json:"echo_for_word"`
EchoForSentence bool `json:"echo_for_sentence"`
}
func SpeechFromSettings(settings config.Settings) SpeechConfiguration {
return SpeechConfiguration{
Server: settings.SpeechServerFactory, Module: settings.SpeechOutputModule,
Device: settings.HardwareSpeechDevice, Baud: settings.HardwareSpeechBaudRate,
DefaultVoice: settings.DefaultVoice, UppercaseVoice: settings.UppercaseVoice,
HyperlinkVoice: settings.HyperlinkVoice, SystemVoice: settings.SystemVoice,
RaisePitchForCapitals: settings.RaisePitchForCapitals,
BeepForCapitals: settings.BeepForCapitals, SayCapForCapitals: settings.SayCapForCapitals,
Punctuation: settings.VerbalizePunctuationStyle,
CustomEcho: settings.UseCustomEchoVoice, CustomEchoServer: settings.UseCustomEchoSpeechServer,
EchoServer: settings.EchoSpeechServer, EchoVoice: settings.EchoVoice,
EchoForKey: settings.UseCustomEchoForKey, EchoForCharacter: settings.UseCustomEchoForCharacter,
EchoForWord: settings.UseCustomEchoForWord, EchoForSentence: settings.UseCustomEchoForSentence,
}
}
func (value SpeechConfiguration) Apply(settings config.Settings) config.Settings {
settings.SpeechServerFactory, settings.SpeechOutputModule = value.Server, value.Module
settings.HardwareSpeechDevice, settings.HardwareSpeechBaudRate = value.Device, value.Baud
settings.DefaultVoice, settings.UppercaseVoice = value.DefaultVoice, value.UppercaseVoice
settings.HyperlinkVoice, settings.SystemVoice = value.HyperlinkVoice, value.SystemVoice
settings.RaisePitchForCapitals, settings.BeepForCapitals, settings.SayCapForCapitals = value.RaisePitchForCapitals, value.BeepForCapitals, value.SayCapForCapitals
settings.VerbalizePunctuationStyle = value.Punctuation
settings.UseCustomEchoVoice, settings.UseCustomEchoSpeechServer = value.CustomEcho, value.CustomEchoServer
settings.EchoSpeechServer, settings.EchoVoice = value.EchoServer, value.EchoVoice
settings.UseCustomEchoForKey, settings.UseCustomEchoForCharacter = value.EchoForKey, value.EchoForCharacter
settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = value.EchoForWord, value.EchoForSentence
return settings
}
type PreferencePage struct {
ID string `json:"id"`
Title string `json:"title"`
Command string `json:"command"`
Load bool `json:"load,omitempty"`
Elements []interaction.Element `json:"elements"`
Values map[string]interaction.Value `json:"values"`
}
type QuickSetting struct {
ID string `json:"id"`
Label string `json:"label"`
Command string `json:"command"`
Options []interaction.Item `json:"options"`
Selected string `json:"selected"`
}
// Extensions are copied at registration/update. IDs are local to one session.
type Extensions struct {
Pages []PreferencePage `json:"pages,omitempty"`
QuickSettings []QuickSetting `json:"quick_settings,omitempty"`
}
// ReaderRequest is serviced on the settings worker. Snapshot returns a copied
// state to Next; Module optionally requests that module's catalog. Mutations
// are atomic within this request and never persist the active speech override.
type ReaderRequest struct {
Snapshot bool `json:"snapshot,omitempty"`
Module string `json:"module,omitempty"`
Speech *SpeechConfiguration `json:"speech_configuration,omitempty"`
RestoreSpeech bool `json:"restore_speech,omitempty"`
Extensions *Extensions `json:"extensions,omitempty"`
}
type ReaderState struct {
Speech SpeechConfiguration `json:"speech_configuration"`
DefaultSpeech SpeechConfiguration `json:"default_speech_configuration"`
Modules []string `json:"modules,omitempty"`
Voices []speech.Voice `json:"voices,omitempty"`
CatalogTruncated bool `json:"catalog_truncated,omitempty"`
Extensions Extensions `json:"extensions"`
}
// ExtensionEvent handlers propose effects only. The host commits page values
// and quick selections after validating all proposals and applying core settings.
type ExtensionEvent struct {
Event string `json:"event"`
ID string `json:"id"`
Values map[string]interaction.Value `json:"values,omitempty"`
Selected string `json:"selected,omitempty"`
Reader ReaderState `json:"reader"`
}
func valid_label(value string) bool {
return strings.TrimSpace(value) != "" && len(value) <= 256 && validFormText(value)
}
func ValidExtensionID(value string) bool {
if len(value) == 0 || len(value) > 64 {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') {
return false
}
}
return true
}
func ValidateExtensions(value Extensions) error {
if len(value.Pages) > 4 || len(value.QuickSettings) > 8 {
return ErrLimit
}
ids := map[string]bool{}
for _, page := range value.Pages {
if !ValidExtensionID(page.ID) || ids[page.ID] || !valid_label(page.Title) || !ValidExtensionID(page.Command) {
return ErrProtocol
}
ids[page.ID] = true
if err := interaction.ValidatePreferences(interaction.Form{Title: page.Title, Elements: page.Elements}); err != nil {
return ErrProtocol
}
for _, element := range page.Elements {
if !ValidExtensionID(element.ID) || !valid_label(element.Label) || element.Kind == interaction.Button {
return ErrProtocol
}
for _, item := range element.Items {
if !ValidExtensionID(item.ID) || !valid_label(item.Label) {
return ErrProtocol
}
}
}
if err := ValidatePageValues(page, page.Values); err != nil {
return err
}
}
clear(ids)
for _, setting := range value.QuickSettings {
if !ValidExtensionID(setting.ID) || ids[setting.ID] || !valid_label(setting.Label) || !ValidExtensionID(setting.Command) || len(setting.Options) == 0 || len(setting.Options) > 64 {
return ErrProtocol
}
ids[setting.ID] = true
options := map[string]bool{}
selected := false
for _, option := range setting.Options {
if !ValidExtensionID(option.ID) || options[option.ID] || !valid_label(option.Label) {
return ErrProtocol
}
options[option.ID] = true
selected = selected || option.ID == setting.Selected && !option.Disabled
}
if !selected {
return ErrProtocol
}
}
data, err := json.Marshal(value)
if err != nil || len(data) > 32*1024 {
return ErrLimit
}
return nil
}
func ValidatePageValues(page PreferencePage, values map[string]interaction.Value) error {
if len(values) != len(page.Elements) {
return errors.New("page values do not match its controls")
}
for _, element := range page.Elements {
value, found := values[element.ID]
if !found {
return ErrProtocol
}
switch element.Kind {
case interaction.Entry:
limit := element.MaxLength
if limit == 0 {
limit = 8192
}
if !validFormText(value.Text) || len([]rune(value.Text)) > limit || value.Selected != "" || value.Checked || element.Required && strings.TrimSpace(value.Text) == "" {
return ErrProtocol
}
case interaction.CheckBox:
if value.Text != "" || value.Selected != "" {
return ErrProtocol
}
case interaction.List:
valid := false
for _, item := range element.Items {
valid = valid || item.ID == value.Selected && !item.Disabled
}
if !valid || value.Text != "" || value.Checked {
return ErrProtocol
}
default:
return ErrProtocol
}
}
return nil
}
func ValidateReaderRequest(value ReaderRequest) error {
if value.Snapshot && (value.Speech != nil || value.RestoreSpeech || value.Extensions != nil) || value.Speech != nil && value.RestoreSpeech || value.Module != "" && !value.Snapshot || len(value.Module) > 256 || !validFormText(value.Module) {
return ErrProtocol
}
if !value.Snapshot && value.Speech == nil && !value.RestoreSpeech && value.Extensions == nil {
return ErrProtocol
}
if value.Speech != nil {
if err := config.Validate(value.Speech.Apply(config.DefaultSettings())); err != nil {
return err
}
}
if value.Extensions != nil {
return ValidateExtensions(*value.Extensions)
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"fmt"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/interaction"
"math"
"reflect"
"testing"
)
func reader_test_extensions() Extensions {
return Extensions{
Pages: []PreferencePage{{ID: "options", Title: "Reader probe", Command: "apply", Elements: []interaction.Element{
{ID: "text", Label: "Text", Kind: interaction.Entry, Required: true, MaxLength: 32},
{ID: "enabled", Label: "Enabled", Kind: interaction.CheckBox},
{ID: "choice", Label: "Choice", Kind: interaction.List, Items: []interaction.Item{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Disabled: true}}},
}, Values: map[string]interaction.Value{"text": {Text: "Initial"}, "enabled": {}, "choice": {Selected: "first"}}}},
QuickSettings: []QuickSetting{{ID: "choice", Label: "Probe choice", Command: "apply", Options: []interaction.Item{{ID: "default", Label: "Default"}, {ID: "custom", Label: "Custom"}}, Selected: "default"}},
}
}
func TestReaderPageCanDescribeSixCompleteVoiceConfigurations(t *testing.T) {
page := PreferencePage{ID: "voices", Title: "Voice configurations", Command: "apply", Values: map[string]interaction.Value{}}
for profile := 1; profile <= 6; profile++ {
for field := 1; field <= 24; field++ {
id := fmt.Sprintf("profile%d.field%d", profile, field)
page.Elements = append(page.Elements, interaction.Element{ID: id, Label: fmt.Sprintf("Profile %d setting %d", profile, field), Kind: interaction.Entry, MaxLength: 64})
page.Values[id] = interaction.Value{Text: "Example"}
}
}
if err := ValidateExtensions(Extensions{Pages: []PreferencePage{page}}); err != nil {
t.Fatal(err)
}
if _, err := interaction.NewSession(interaction.Form{Title: page.Title, Elements: page.Elements}); err == nil {
t.Fatal("native page limit leaked into internal forms")
}
state := ReaderState{Extensions: Extensions{Pages: []PreferencePage{page}}, Speech: SpeechFromSettings(config.DefaultSettings()), DefaultSpeech: SpeechFromSettings(config.DefaultSettings())}
data, err := json.Marshal(ExtensionEvent{Event: "preferences.apply", ID: page.ID, Values: page.Values, Reader: state})
if err != nil || len(data) > 48*1024 {
t.Fatal("six configurations exceed callback budget", len(data), err)
}
}
func TestReaderExtensionsRejectInvalidSchemasAndValues(t *testing.T) {
mutations := map[string]func(*Extensions){
"duplicate page": func(v *Extensions) { v.Pages = append(v.Pages, v.Pages[0]) },
"duplicate setting": func(v *Extensions) { v.QuickSettings = append(v.QuickSettings, v.QuickSettings[0]) },
"duplicate control": func(v *Extensions) { v.Pages[0].Elements = append(v.Pages[0].Elements, v.Pages[0].Elements[0]) },
"button": func(v *Extensions) { v.Pages[0].Elements[0].Kind = interaction.Button },
"bad label": func(v *Extensions) { v.Pages[0].Title = "Probe\npage" },
"bad identifier": func(v *Extensions) { v.Pages[0].ID = "../page" },
"missing value": func(v *Extensions) { delete(v.Pages[0].Values, "text") },
"extra value": func(v *Extensions) { v.Pages[0].Values["other"] = interaction.Value{} },
"wrong value type": func(v *Extensions) { v.Pages[0].Values["enabled"] = interaction.Value{Text: "yes"} },
"required": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{} },
"oversized": func(v *Extensions) {
v.Pages[0].Values["text"] = interaction.Value{Text: "This string is longer than thirty-two characters"}
},
"control character": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{Text: "bad\x1bvalue"} },
"disabled choice": func(v *Extensions) { v.Pages[0].Values["choice"] = interaction.Value{Selected: "second"} },
"unknown quick choice": func(v *Extensions) { v.QuickSettings[0].Selected = "missing" },
"disabled quick choice": func(v *Extensions) { v.QuickSettings[0].Options[0].Disabled = true },
}
if err := ValidateExtensions(reader_test_extensions()); err != nil {
t.Fatal(err)
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
value := reader_test_extensions()
mutate(&value)
if ValidateExtensions(value) == nil {
t.Fatal("accepted invalid extension")
}
})
}
}
func TestReaderSpeechConfigurationRoundTripAndIsolation(t *testing.T) {
settings := config.DefaultSettings()
rate, pitch, gain := 72.0, 6.2, 8.1
settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, Family: &config.VoiceFamily{Name: "Example voice", Lang: "en", Dialect: "US", Variant: "sample"}}
settings.UppercaseVoice = config.Voice{AveragePitch: &pitch}
settings.HyperlinkVoice = config.Voice{Rate: &rate}
settings.SystemVoice = config.Voice{Gain: &gain}
settings.UseCustomEchoVoice = true
settings.EchoVoice = config.Voice{Rate: &rate}
base := config.DefaultSettings()
base.EnableSpeech = false
base.EnableBraille = true
result := SpeechFromSettings(settings).Apply(base)
if !reflect.DeepEqual(SpeechFromSettings(result), SpeechFromSettings(settings)) {
t.Fatal("voice settings lost")
}
if result.EnableSpeech || !result.EnableBraille || result.KeyboardLayout != base.KeyboardLayout {
t.Fatal("unrelated settings changed")
}
value := SpeechFromSettings(settings)
value.DefaultVoice.Rate = new(float64)
*value.DefaultVoice.Rate = math.NaN()
if ValidateReaderRequest(ReaderRequest{Speech: &value}) == nil {
t.Fatal("accepted NaN")
}
}
func TestReaderInteractionValidation(t *testing.T) {
for _, raw := range []string{
`{"reader":{"snapshot":true},"next":"receive"}`,
`{"reader":{"restore_speech":true}}`,
`{"reader":{"extensions":{}}}`,
} {
if _, err := DecodeInteraction([]byte(raw)); err != nil {
t.Fatal(raw, err)
}
}
for _, raw := range []string{
`{"reader":{"snapshot":true}}`,
`{"reader":{"snapshot":true,"restore_speech":true},"next":"receive"}`,
`{"reader":{"restore_speech":true},"clipboard":"value"}`,
`{"reader":{"restore_speech":true},"done":true}`,
`{"reader":{"module":"example"}}`,
`{"reader":{"restore_speech":true,"unknown":true}}`,
`{"reader":null}`,
} {
if _, err := DecodeInteraction([]byte(raw)); err == nil {
t.Fatal("accepted", raw)
}
}
value := reader_test_extensions()
data, _ := json.Marshal(Interaction{Reader: &ReaderRequest{Extensions: &value}})
if _, err := DecodeInteraction(data); err != nil {
t.Fatal(err)
}
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package tentaclewire shares bounded framing between the host and Go SDK.
package tentaclewire
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"unicode/utf8"
)
const MaxFrameBytes = 64 * 1024
var ErrProtocol = errors.New("invalid tentacle protocol message")
var ErrLimit = errors.New("tentacle resource limit exceeded")
// ReadFrame bounds allocation even when the child never writes a newline.
func ReadFrame(reader *bufio.Reader) ([]byte, error) {
var frame []byte
for {
part, err := reader.ReadSlice('\n')
if len(frame)+len(part) > MaxFrameBytes+1 {
return nil, ErrLimit
}
frame = append(frame, part...)
if err == nil {
return frame[:len(frame)-1], nil
}
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
if errors.Is(err, io.EOF) && len(frame) != 0 {
return nil, io.ErrUnexpectedEOF
}
return nil, err
}
}
// Reject duplicate keys and excessive nesting, including in command arguments
// and results. Different languages must not interpret the same frame differently.
func ValidateJSON(data []byte) error {
if !utf8.Valid(data) {
return ErrProtocol
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := validate_value(decoder, 0); err != nil {
return ErrProtocol
}
if _, err := decoder.Token(); err != io.EOF {
return ErrProtocol
}
return nil
}
func validate_value(decoder *json.Decoder, depth int) error {
if depth > 32 {
return ErrLimit
}
token, err := decoder.Token()
if err != nil {
return err
}
delimiter, composite := token.(json.Delim)
if !composite {
return nil
}
if delimiter != '{' && delimiter != '[' {
return ErrProtocol
}
keys := map[string]bool{}
for decoder.More() {
if delimiter == '{' {
keyToken, err := decoder.Token()
if err != nil {
return err
}
key, ok := keyToken.(string)
if !ok || keys[key] {
return ErrProtocol
}
keys[key] = true
}
if err := validate_value(decoder, depth+1); err != nil {
return err
}
}
_, err = decoder.Token()
return err
}
func DecodeStrict(data []byte, target any) error {
if err := ValidateJSON(data); err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return ErrProtocol
}
return nil
}