From 60b880498dd41b2d39a0fd14df1ea9cbc32616c4 Mon Sep 17 00:00:00 2001 From: Patrick Colton Date: Tue, 11 Aug 2026 19:27:38 -0400 Subject: [PATCH] fix(publisher): accept UTF-8 BOM in server.json Co-Authored-By: Claude Fable 5 --- cmd/publisher/commands/publish.go | 1 + cmd/publisher/commands/publish_test.go | 14 ++++++++++++++ cmd/publisher/commands/unicode.go | 11 +++++++++++ cmd/publisher/commands/validate.go | 1 + cmd/publisher/commands/validate_test.go | 14 ++++++++++++++ 5 files changed, 41 insertions(+) diff --git a/cmd/publisher/commands/publish.go b/cmd/publisher/commands/publish.go index 902c42139..746f6016d 100644 --- a/cmd/publisher/commands/publish.go +++ b/cmd/publisher/commands/publish.go @@ -28,6 +28,7 @@ func PublishCommand(args []string) error { } return fmt.Errorf("failed to read server.json: %w", err) } + serverData = stripUTF8BOM(serverData) if err := validateJSONUnicode(serverFile, serverData); err != nil { return err } diff --git a/cmd/publisher/commands/publish_test.go b/cmd/publisher/commands/publish_test.go index 98c19a323..2ee471ea8 100644 --- a/cmd/publisher/commands/publish_test.go +++ b/cmd/publisher/commands/publish_test.go @@ -81,6 +81,20 @@ func TestPublishCommand_RejectsInvalidUTF8ServerJSON(t *testing.T) { assert.Contains(t, err.Error(), "UTF-8") } +func TestPublishCommand_AcceptsUTF8BOM(t *testing.T) { + // Windows tools such as PowerShell's Out-File write UTF-8 with a leading + // byte order mark; RFC 8259 permits parsers to ignore it. + server := SetupMockRegistryServer(t, nil, nil) + SetupTestToken(t, server.URL, "test-token") + + bom := []byte{0xEF, 0xBB, 0xBF} + body := []byte(`{"$schema":"` + model.CurrentSchemaURL + `","name":"com.example/test","description":"A test server","version":"1.0.0"}`) + createRawServerJSON(t, append(bom, body...)) + + err := commands.PublishCommand([]string{}) + assert.NoError(t, err) +} + func TestPublishCommand_RejectsUnpairedSurrogateEscape(t *testing.T) { createRawServerJSON(t, []byte(`{"$schema":"","name":"com.example/test","description":"bad \udc94","version":"1.0.0"}`)) diff --git a/cmd/publisher/commands/unicode.go b/cmd/publisher/commands/unicode.go index 11f515334..829837180 100644 --- a/cmd/publisher/commands/unicode.go +++ b/cmd/publisher/commands/unicode.go @@ -8,6 +8,17 @@ import ( "unicode/utf8" ) +// utf8BOM is the UTF-8 byte order mark. Some Windows tools (e.g. PowerShell's +// Out-File) prepend it when writing UTF-8 files. RFC 8259 permits JSON parsers +// to ignore it, but Go's encoding/json rejects it, so it is stripped before +// parsing. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// stripUTF8BOM returns data without a leading UTF-8 byte order mark. +func stripUTF8BOM(data []byte) []byte { + return bytes.TrimPrefix(data, utf8BOM) +} + func validateJSONUnicode(filename string, data []byte) error { if !utf8.Valid(data) { return fmt.Errorf("%s must be encoded as UTF-8", filename) diff --git a/cmd/publisher/commands/validate.go b/cmd/publisher/commands/validate.go index 012e8fe78..333544193 100644 --- a/cmd/publisher/commands/validate.go +++ b/cmd/publisher/commands/validate.go @@ -142,6 +142,7 @@ func ValidateCommand(args []string) error { } return fmt.Errorf("failed to read %s: %w", serverFile, err) } + serverData = stripUTF8BOM(serverData) if err := validateJSONUnicode(serverFile, serverData); err != nil { return err } diff --git a/cmd/publisher/commands/validate_test.go b/cmd/publisher/commands/validate_test.go index b56b22db2..d6c56389f 100644 --- a/cmd/publisher/commands/validate_test.go +++ b/cmd/publisher/commands/validate_test.go @@ -136,6 +136,20 @@ func TestValidateCommand_DeprecatedSchema(t *testing.T) { assert.Contains(t, err.Error(), "Migration checklist:") } +func TestValidateCommand_AcceptsUTF8BOM(t *testing.T) { + // Windows tools such as PowerShell's Out-File write UTF-8 with a leading + // byte order mark; RFC 8259 permits parsers to ignore it. + server := SetupMockRegistryServer(t, nil, nil) + SetupTestToken(t, server.URL, "test-token") + + bom := []byte{0xEF, 0xBB, 0xBF} + body := []byte(`{"$schema":"` + model.CurrentSchemaURL + `","name":"com.example/test","description":"A test server","version":"1.0.0"}`) + createRawServerJSON(t, append(bom, body...)) + + err := commands.ValidateCommand([]string{}) + assert.NoError(t, err) +} + func TestValidateCommand_NoServerFile(t *testing.T) { server := SetupMockRegistryServer(t, nil, nil) SetupTestToken(t, server.URL, "test-token")