Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,49 @@ func (f frame) result(found bool) ([]byte, error) {
}
}

// utf16Len counts a string the way the service does: in UTF-16 code units, one
// per character up to U+FFFF and two beyond it. This is what JavaScript's
// String.length and Java's String.length() report, and the framed payload
// lengths are produced by that side.
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r > 0xFFFF {
n += 2
} else {
n++
}
}
return n
}

// utf16Split cuts s after n UTF-16 code units, returning the prefix and the
// rest. ok is false when s holds fewer than n units.
func utf16Split(s string, n int) (prefix, rest string, ok bool) {
units := 0
for i, r := range s {
if units == n {
return s[:i], s[i:], true
}
if r > 0xFFFF {
units += 2
} else {
units++
}
}
if units == n {
return s, "", true
}
return "", s, false
}

// parseFrames decodes a framed command response (PROTOCOL.md §3) into a map of
// command id → frame. A leading E/D/B status is a session-level error.
//
// Payload lengths in the framing count characters, not bytes, so they are
// consumed in UTF-16 code units. Slicing by bytes works only while everything
// is ASCII and silently truncates the moment it is not — a localized error
// message or an accented file name loses its tail.
func parseFrames(body string) (map[string]frame, error) {
if body == "" {
return nil, fmt.Errorf("empty response")
Expand Down Expand Up @@ -293,11 +334,11 @@ func parseFrames(body string) (map[string]frame, error) {
return nil, fmt.Errorf("malformed length: %w", err)
}
i += l + 1
if i+n > len(body) {
payload, rest, ok := utf16Split(body[i:], n)
if !ok {
return nil, fmt.Errorf("truncated payload")
}
payload := body[i : i+n]
i += n
i = len(body) - len(rest)
if len(payload) < 1 {
continue
}
Expand Down
51 changes: 50 additions & 1 deletion internal/session/session_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package session

import "testing"
import (
"strconv"
"testing"
)

// resultFor decodes the frame for id from body (test helper mirroring Execute).
func resultFor(t *testing.T, body, id string) ([]byte, error) {
Expand Down Expand Up @@ -69,3 +72,49 @@ func TestFrameMissing(t *testing.T) {
t.Fatal("missing frame should error")
}
}

// The framed length counts characters, not bytes: the service is counting the
// way JavaScript and Java do. Slicing the body by bytes truncates any payload
// containing non-ASCII — a localized error message, an accented file name —
// and leaves the caller with a payload cut mid-character.
func TestParseFramesLengthCountsCharactersNotBytes(t *testing.T) {
payload := `K:{"message":"L'agente 'x' già esiste.","status":"error"}`
body := "K:K1:" + strconv.Itoa(len([]rune(payload))) + ":" + payload

frames, err := parseFrames(body)
if err != nil {
t.Fatalf("parseFrames: %v", err)
}
f, ok := frames["K1"]
if !ok {
t.Fatal("frame K1 missing")
}
data, err := f.result(true)
if err != nil {
t.Fatalf("result: %v", err)
}
want := `{"message":"L'agente 'x' già esiste.","status":"error"}`
if string(data) != want {
t.Fatalf("payload truncated:\n got %q\nwant %q", data, want)
}
}

// Beyond the basic plane a character is two UTF-16 units, which is what the
// service counts — counting Go runes there would trade one off-by-one for
// another.
func TestParseFramesCountsUTF16Units(t *testing.T) {
payload := `K:{"n":"😀"}` // the emoji is one rune but two UTF-16 units
body := "K:K1:" + strconv.Itoa(utf16Len(payload)) + ":" + payload

frames, err := parseFrames(body)
if err != nil {
t.Fatalf("parseFrames: %v", err)
}
data, err := frames["K1"].result(true)
if err != nil {
t.Fatalf("result: %v", err)
}
if string(data) != `{"n":"😀"}` {
t.Fatalf("got %q", data)
}
}