Skip to content

Commit d5028b5

Browse files
committed
Fix unhelpful error when extension locale file has invalid UTF-8 (shop/issues-develop#21558)
Validate UTF-8 encoding of locale JSON files in `loadLocalesConfig` before base64-encoding them for upload. Previously, a single invalid byte (e.g. a Latin-1 "à" left in an Italian translation) would reach the server and surface only as a generic INTERNAL_SERVER_ERROR raised from `String#strip` deep in the framework's localization decoder. The new check fails fast on the CLI side with the offending locale file path and an actionable hint to re-save it as UTF-8.
1 parent de64e8f commit d5028b5

3 files changed

Lines changed: 49 additions & 4 deletions

File tree

.changeset/validate-locale-utf8.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Fail UI extension `dev` and `deploy` early with a clear error pointing at the offending locale file when a `locales/*.json` file contains invalid UTF-8 byte sequences, instead of letting the upload reach the server and abort with a generic `INTERNAL_SERVER_ERROR`.

packages/app/src/cli/utilities/extensions/locales-configuration.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {loadLocalesConfig} from './locales-configuration.js'
22
import {describe, expect, test} from 'vitest'
33
import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
44
import {joinPath} from '@shopify/cli-kit/node/path'
5+
import fs from 'fs'
56

67
describe('loadLocalesConfig', () => {
78
test('Works if all locales are correct', async () => {
@@ -74,4 +75,34 @@ describe('loadLocalesConfig', () => {
7475
await expect(got).rejects.toThrow(/Error loading checkout_ui/)
7576
})
7677
})
78+
79+
test('Throws with a helpful message if a locale file contains invalid UTF-8 bytes', async () => {
80+
await inTemporaryDirectory(async (tmpDir: string) => {
81+
// Given
82+
const localesPath = joinPath(tmpDir, 'locales')
83+
const enDefault = joinPath(localesPath, 'en.default.json')
84+
const it = joinPath(localesPath, 'it.json')
85+
86+
await mkdir(localesPath)
87+
await writeFile(enDefault, JSON.stringify({hello: 'Hello'}))
88+
// 0xE0 starts a 3-byte UTF-8 sequence but is followed by an ASCII space,
89+
// mirroring the Latin-1 encoded "sarà" (`sar\xE0`) reported in shop/issues-develop#21558.
90+
const invalidBytes = Buffer.concat([
91+
Buffer.from('{"hello":"sar', 'utf8'),
92+
Buffer.from([0xe0]),
93+
Buffer.from(' "}', 'utf8'),
94+
])
95+
fs.writeFileSync(it, invalidBytes)
96+
97+
// When
98+
const got = loadLocalesConfig(tmpDir, 'checkout_ui')
99+
await expect(got).rejects.toThrow(/Error loading checkout_ui/)
100+
await expect(got).rejects.toMatchObject({
101+
tryMessage: expect.stringMatching(/invalid UTF-8 byte sequences/),
102+
})
103+
await expect(got).rejects.toMatchObject({
104+
tryMessage: expect.stringMatching(/it\.json/),
105+
})
106+
})
107+
})
77108
})

packages/app/src/cli/utilities/extensions/locales-configuration.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {joinPath, basename} from '@shopify/cli-kit/node/path'
22
import {glob} from '@shopify/cli-kit/node/fs'
33
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
4+
import {isUtf8} from 'node:buffer'
45
import fs from 'fs'
56

67
export async function loadLocalesConfig(extensionPath: string, extensionIdentifier: string) {
@@ -30,7 +31,7 @@ export async function loadLocalesConfig(extensionPath: string, extensionIdentifi
3031

3132
return {
3233
default_locale: defaultLanguageCode[0],
33-
translations: getAllLocales(localesPaths),
34+
translations: getAllLocales(localesPaths, extensionIdentifier),
3435
}
3536
}
3637

@@ -39,12 +40,20 @@ function findDefaultLocale(filePaths: string[]) {
3940
return defaultLocale.map((locale) => basename(locale).split('.')[0])
4041
}
4142

42-
function getAllLocales(localesPath: string[]) {
43+
function getAllLocales(localesPath: string[], extensionIdentifier: string) {
4344
const all: {[key: string]: string} = {}
4445
for (const localePath of localesPath) {
4546
const localeCode = failIfUnset(basename(localePath).split('.')[0], 'Locale code is unset')
46-
const locale = fs.readFileSync(localePath, 'base64')
47-
all[localeCode] = locale
47+
const localeBuffer = fs.readFileSync(localePath)
48+
// Validate UTF-8 client-side: the server decodes these as UTF-8 strings and a
49+
// single invalid byte sequence aborts the upload with an unhelpful error.
50+
if (!isUtf8(localeBuffer)) {
51+
throw new AbortError(
52+
`Error loading ${extensionIdentifier}`,
53+
`Locale file ${localePath} contains invalid UTF-8 byte sequences. Re-save the file using UTF-8 encoding.`,
54+
)
55+
}
56+
all[localeCode] = localeBuffer.toString('base64')
4857
}
4958
return all
5059
}

0 commit comments

Comments
 (0)