Skip to content

Commit 4ddfd1a

Browse files
Fix Windows codepage transformer to handle streaming correctly
Address Copilot review: The windowsDecoder and windowsEncoder now properly handle the atEOF parameter and buffer incomplete sequences between Transform calls. This ensures correct behavior when transform.Reader/Writer splits multibyte sequences across chunks. Changes: - Add buffer fields to windowsDecoder and windowsEncoder structs - Use MB_ERR_INVALID_CHARS to detect incomplete sequences in decoder - Use utf8.Valid to detect incomplete UTF-8 sequences in encoder - Return transform.ErrShortSrc when more input is needed - Return error for incomplete sequences at EOF - Add TestWindowsEncodingStreaming test for streaming behavior
1 parent b96e228 commit 4ddfd1a

2 files changed

Lines changed: 220 additions & 20 deletions

File tree

pkg/sqlcmd/codepage_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/stretchr/testify/assert"
11+
"golang.org/x/text/transform"
1112
)
1213

1314
func TestParseCodePage(t *testing.T) {
@@ -316,3 +317,74 @@ func TestGetEncodingWindowsFallback(t *testing.T) {
316317
assert.Error(t, err, "invalid codepage should fail on all platforms")
317318
assert.Contains(t, err.Error(), "codepage")
318319
}
320+
321+
func TestWindowsEncodingStreaming(t *testing.T) {
322+
// This test verifies that the Windows API fallback handles streaming correctly
323+
// by properly buffering incomplete multibyte sequences
324+
325+
// Japanese EBCDIC (20290) is a good test case as it's only available via Windows API
326+
cp := 20290 // IBM EBCDIC Japanese Katakana Extended
327+
328+
enc, err := GetEncoding(cp)
329+
if err != nil {
330+
t.Skip("Codepage 20290 not available on this platform")
331+
}
332+
333+
// Test decoder streaming with transform.Reader
334+
t.Run("decoder streaming", func(t *testing.T) {
335+
// Create a simple EBCDIC encoded string: "ABC" = 0xC1 0xC2 0xC3
336+
ebcdicData := []byte{0xC1, 0xC2, 0xC3}
337+
338+
decoder := enc.NewDecoder()
339+
340+
// Simulate streaming by processing one byte at a time
341+
var result []byte
342+
for i := 0; i < len(ebcdicData); i++ {
343+
decoder.Reset() // Reset between chunks for clean state
344+
dst := make([]byte, 32)
345+
nDst, _, err := decoder.Transform(dst, ebcdicData[i:i+1], i == len(ebcdicData)-1)
346+
if err != nil && err != transform.ErrShortSrc {
347+
t.Fatalf("Transform failed at byte %d: %v", i, err)
348+
}
349+
result = append(result, dst[:nDst]...)
350+
}
351+
assert.Equal(t, "ABC", string(result), "streaming decode should produce 'ABC'")
352+
})
353+
354+
// Test encoder streaming
355+
t.Run("encoder streaming", func(t *testing.T) {
356+
// Test encoding "ABC" one character at a time
357+
input := "ABC"
358+
encoder := enc.NewEncoder()
359+
360+
var result []byte
361+
for i := 0; i < len(input); i++ {
362+
encoder.Reset() // Reset between chunks for clean state
363+
dst := make([]byte, 32)
364+
nDst, _, err := encoder.Transform(dst, []byte(input[i:i+1]), i == len(input)-1)
365+
if err != nil && err != transform.ErrShortSrc {
366+
t.Fatalf("Transform failed at char %d: %v", i, err)
367+
}
368+
result = append(result, dst[:nDst]...)
369+
}
370+
expected := []byte{0xC1, 0xC2, 0xC3} // "ABC" in EBCDIC
371+
assert.Equal(t, expected, result, "streaming encode should produce EBCDIC ABC")
372+
})
373+
374+
// Test encoder handles incomplete UTF-8 correctly
375+
t.Run("encoder incomplete UTF-8", func(t *testing.T) {
376+
encoder := enc.NewEncoder()
377+
dst := make([]byte, 32)
378+
379+
// Send first byte of a 2-byte UTF-8 sequence (é = 0xC3 0xA9)
380+
incompleteUTF8 := []byte{0xC3} // First byte of é
381+
_, _, err := encoder.Transform(dst, incompleteUTF8, false)
382+
// Should return ErrShortSrc because the sequence is incomplete
383+
assert.Equal(t, transform.ErrShortSrc, err, "incomplete UTF-8 should return ErrShortSrc when not at EOF")
384+
385+
// At EOF, incomplete sequence should be an error
386+
encoder.Reset()
387+
_, _, err = encoder.Transform(dst, incompleteUTF8, true)
388+
assert.Error(t, err, "incomplete UTF-8 at EOF should return error")
389+
})
390+
}

pkg/sqlcmd/codepage_windows.go

Lines changed: 148 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"errors"
1010
"strconv"
1111
"unicode/utf16"
12+
"unicode/utf8"
1213
"unsafe"
1314

1415
"github.com/microsoft/go-sqlcmd/internal/localizer"
@@ -17,6 +18,15 @@ import (
1718
"golang.org/x/text/transform"
1819
)
1920

21+
const (
22+
// MB_ERR_INVALID_CHARS causes MultiByteToWideChar to fail if it encounters
23+
// an invalid character in the source string (including incomplete sequences)
24+
mbErrInvalidChars = 0x00000008
25+
// Maximum bytes that might form a single character in any Windows codepage
26+
// (most DBCS codepages use 2 bytes, but we use 4 for safety)
27+
maxMultibyteCharLen = 4
28+
)
29+
2030
var (
2131
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
2232
procMultiByteToWideChar = kernel32.NewProc("MultiByteToWideChar")
@@ -36,43 +46,113 @@ func (e *windowsCodePageEncoding) NewEncoder() *encoding.Encoder {
3646
return &encoding.Encoder{Transformer: &windowsEncoder{codepage: e.codepage}}
3747
}
3848

39-
// windowsDecoder converts from a Windows codepage to UTF-8
49+
// windowsDecoder converts from a Windows codepage to UTF-8.
50+
// It buffers incomplete multibyte sequences between Transform calls.
4051
type windowsDecoder struct {
4152
codepage uint32
53+
buf [maxMultibyteCharLen]byte // buffer for incomplete sequences
54+
bufLen int // number of bytes in buffer
4255
}
4356

44-
func (d *windowsDecoder) Reset() {}
57+
func (d *windowsDecoder) Reset() {
58+
d.bufLen = 0
59+
}
4560

4661
func (d *windowsDecoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
47-
if len(src) == 0 {
62+
// Prepend any buffered bytes from previous call
63+
var input []byte
64+
if d.bufLen > 0 {
65+
input = make([]byte, d.bufLen+len(src))
66+
copy(input, d.buf[:d.bufLen])
67+
copy(input[d.bufLen:], src)
68+
} else {
69+
input = src
70+
}
71+
72+
if len(input) == 0 {
4873
return 0, 0, nil
4974
}
5075

51-
// First call to get required buffer size for wide chars
76+
// Try to convert with MB_ERR_INVALID_CHARS to detect incomplete sequences
5277
n, _, errno := procMultiByteToWideChar.Call(
5378
uintptr(d.codepage),
54-
0,
55-
uintptr(unsafe.Pointer(&src[0])),
56-
uintptr(len(src)),
79+
mbErrInvalidChars,
80+
uintptr(unsafe.Pointer(&input[0])),
81+
uintptr(len(input)),
5782
0,
5883
0,
5984
)
60-
if n == 0 {
85+
86+
// If conversion failed, it might be due to incomplete trailing sequence
87+
if n == 0 && errno == windows.ERROR_NO_UNICODE_TRANSLATION {
88+
if atEOF {
89+
// At EOF with incomplete sequence - this is an error
90+
d.bufLen = 0
91+
return 0, len(src), errors.New("incomplete multibyte sequence at end of input")
92+
}
93+
94+
// Not at EOF - try removing bytes from the end until conversion succeeds
95+
// This finds the incomplete trailing sequence
96+
for trimLen := 1; trimLen <= len(input) && trimLen <= maxMultibyteCharLen; trimLen++ {
97+
tryLen := len(input) - trimLen
98+
if tryLen <= 0 {
99+
// Need more input - buffer what we have
100+
if len(input) <= maxMultibyteCharLen {
101+
copy(d.buf[:], input)
102+
d.bufLen = len(input)
103+
return 0, len(src), transform.ErrShortSrc
104+
}
105+
break
106+
}
107+
108+
n, _, errno = procMultiByteToWideChar.Call(
109+
uintptr(d.codepage),
110+
mbErrInvalidChars,
111+
uintptr(unsafe.Pointer(&input[0])),
112+
uintptr(tryLen),
113+
0,
114+
0,
115+
)
116+
if n > 0 || errno != windows.ERROR_NO_UNICODE_TRANSLATION {
117+
// Found a valid prefix - buffer the trailing bytes
118+
trailingBytes := input[tryLen:]
119+
copy(d.buf[:], trailingBytes)
120+
d.bufLen = len(trailingBytes)
121+
input = input[:tryLen]
122+
break
123+
}
124+
}
125+
126+
// If still failing, buffer everything and wait for more
127+
if n == 0 {
128+
if len(input) <= maxMultibyteCharLen {
129+
copy(d.buf[:], input)
130+
d.bufLen = len(input)
131+
return 0, len(src), transform.ErrShortSrc
132+
}
133+
// Input is larger than max char length but still invalid - real error
134+
d.bufLen = 0
135+
return 0, len(src), errors.New("invalid multibyte sequence")
136+
}
137+
} else if n == 0 {
61138
if errno != windows.ERROR_SUCCESS {
139+
d.bufLen = 0
62140
return 0, 0, errno
63141
}
142+
d.bufLen = 0
64143
return 0, 0, errors.New("MultiByteToWideChar failed")
144+
} else {
145+
// Success - clear buffer since we'll consume all input
146+
d.bufLen = 0
65147
}
66148

67-
// Allocate wide char buffer
149+
// Allocate wide char buffer and do the actual conversion
68150
wideChars := make([]uint16, n)
69-
70-
// Convert to wide chars
71151
n, _, errno = procMultiByteToWideChar.Call(
72152
uintptr(d.codepage),
73-
0,
74-
uintptr(unsafe.Pointer(&src[0])),
75-
uintptr(len(src)),
153+
0, // Don't use MB_ERR_INVALID_CHARS here - we already validated
154+
uintptr(unsafe.Pointer(&input[0])),
155+
uintptr(len(input)),
76156
uintptr(unsafe.Pointer(&wideChars[0])),
77157
uintptr(len(wideChars)),
78158
)
@@ -92,23 +172,71 @@ func (d *windowsDecoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int,
92172
}
93173

94174
copy(dst, utf8Bytes)
95-
return len(utf8Bytes), len(src), nil
175+
return len(utf8Bytes), len(src), err
96176
}
97177

98-
// windowsEncoder converts from UTF-8 to a Windows codepage
178+
// windowsEncoder converts from UTF-8 to a Windows codepage.
179+
// It buffers incomplete UTF-8 sequences between Transform calls.
99180
type windowsEncoder struct {
100181
codepage uint32
182+
buf [utf8.UTFMax]byte // buffer for incomplete UTF-8 sequences
183+
bufLen int // number of bytes in buffer
101184
}
102185

103-
func (e *windowsEncoder) Reset() {}
186+
func (e *windowsEncoder) Reset() {
187+
e.bufLen = 0
188+
}
104189

105190
func (e *windowsEncoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
106-
if len(src) == 0 {
191+
// Prepend any buffered bytes from previous call
192+
var input []byte
193+
if e.bufLen > 0 {
194+
input = make([]byte, e.bufLen+len(src))
195+
copy(input, e.buf[:e.bufLen])
196+
copy(input[e.bufLen:], src)
197+
} else {
198+
input = src
199+
}
200+
201+
if len(input) == 0 {
107202
return 0, 0, nil
108203
}
109204

205+
// Find the last complete UTF-8 sequence
206+
validLen := len(input)
207+
for validLen > 0 && !utf8.Valid(input[:validLen]) {
208+
validLen--
209+
}
210+
211+
// Check for incomplete trailing sequence
212+
if validLen < len(input) {
213+
trailingBytes := input[validLen:]
214+
if atEOF {
215+
// At EOF with incomplete UTF-8 - this is an error
216+
e.bufLen = 0
217+
return 0, len(src), errors.New("incomplete UTF-8 sequence at end of input")
218+
}
219+
// Buffer the incomplete trailing bytes for next call
220+
if len(trailingBytes) <= utf8.UTFMax {
221+
copy(e.buf[:], trailingBytes)
222+
e.bufLen = len(trailingBytes)
223+
} else {
224+
// Shouldn't happen with valid partial UTF-8, but handle it
225+
e.bufLen = 0
226+
return 0, len(src), errors.New("invalid UTF-8 sequence")
227+
}
228+
input = input[:validLen]
229+
} else {
230+
e.bufLen = 0
231+
}
232+
233+
if len(input) == 0 {
234+
// Only incomplete sequence - need more input
235+
return 0, len(src), transform.ErrShortSrc
236+
}
237+
110238
// Convert UTF-8 to UTF-16
111-
runes := []rune(string(src))
239+
runes := []rune(string(input))
112240
wideChars := utf16.Encode(runes)
113241

114242
if len(wideChars) == 0 {
@@ -155,7 +283,7 @@ func (e *windowsEncoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int,
155283
return 0, 0, errors.New("WideCharToMultiByte failed")
156284
}
157285

158-
return int(n), len(src), nil
286+
return int(n), len(src), err
159287
}
160288

161289
// isCodePageValid checks if a codepage is valid/installed on Windows

0 commit comments

Comments
 (0)