Reset master to testing content
This commit is contained in:
1 parent
0f2587b719
commit
42afa2a56c
1483 files changed
+145308
-938069
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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: ©})
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user