diff --git a/internal/session/session.go b/internal/session/session.go index 3d1a152..8168ec4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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") @@ -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 } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 0188c36..185a0ff 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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) { @@ -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) + } +}