Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,108 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.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) > 3 || apis[0] != "commands" {
|
||||
return false
|
||||
}
|
||||
if len(apis) >= 2 && apis[1] != "presentation" || len(apis) == 3 && apis[2] != "interaction" {
|
||||
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,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