diff --git a/src/lib/components/variables/importVariablesModal.svelte b/src/lib/components/variables/importVariablesModal.svelte index 9eed108b23..2aa3d27490 100644 --- a/src/lib/components/variables/importVariablesModal.svelte +++ b/src/lib/components/variables/importVariablesModal.svelte @@ -5,7 +5,7 @@ import type { Models } from '@appwrite.io/console'; import { IconInfo } from '@appwrite.io/pink-icons-svelte'; import { Icon, Layout, Selector, Tooltip, Typography, Upload } from '@appwrite.io/pink-svelte'; - import { parse } from '$lib/helpers/envfile'; + import { parse, readEnvFile } from '$lib/helpers/envfile'; import { removeFile } from '$lib/helpers/files'; import { validateVariables } from '$lib/helpers/variables'; @@ -31,7 +31,7 @@ throw new Error('No file selected'); } - const uploaded = parse(await files[0].text()); + const uploaded = parse(await readEnvFile(files[0])); if (!Object.keys(uploaded).length) { throw new Error('No variables found'); diff --git a/src/lib/helpers/envfile.test.ts b/src/lib/helpers/envfile.test.ts new file mode 100644 index 0000000000..9a984d722b --- /dev/null +++ b/src/lib/helpers/envfile.test.ts @@ -0,0 +1,54 @@ +import { parse, readEnvFile } from '$lib/helpers/envfile'; +import { expect, test } from 'vitest'; + +function encodeUtf16(text: string, littleEndian: boolean, bom: boolean): ArrayBuffer { + const codeUnits = bom + ? [0xfeff, ...text.split('').map((c) => c.charCodeAt(0))] + : text.split('').map((c) => c.charCodeAt(0)); + const buffer = new ArrayBuffer(codeUnits.length * 2); + const view = new DataView(buffer); + codeUnits.forEach((unit, i) => view.setUint16(i * 2, unit, littleEndian)); + return buffer; +} + +const ENV = 'ACME_SERVICE_API_KEY=secret-value\nOTHER_KEY=other'; +const EXPECTED = { ACME_SERVICE_API_KEY: 'secret-value', OTHER_KEY: 'other' }; + +test('reads UTF-8', async () => { + const file = new Blob([new TextEncoder().encode(ENV)]); + expect(parse(await readEnvFile(file))).toEqual(EXPECTED); +}); + +test('reads UTF-8 with BOM', async () => { + const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(ENV)]); + expect(parse(await readEnvFile(new Blob([bytes])))).toEqual(EXPECTED); +}); + +test('reads UTF-16LE with BOM (PowerShell default)', async () => { + const file = new Blob([encodeUtf16(ENV, true, true)]); + expect(parse(await readEnvFile(file))).toEqual(EXPECTED); +}); + +test('reads UTF-16BE with BOM', async () => { + const file = new Blob([encodeUtf16(ENV, false, true)]); + expect(parse(await readEnvFile(file))).toEqual(EXPECTED); +}); + +test('reads BOM-less UTF-16LE by NUL heuristic', async () => { + const file = new Blob([encodeUtf16(ENV, true, false)]); + const parsed = parse(await readEnvFile(file)); + expect(parsed).toEqual(EXPECTED); + // The regression this guards: keys must not carry interleaved NUL bytes. + expect(Object.keys(parsed).some((key) => key.includes('\u0000'))).toBe(false); +}); + +test('reads BOM-less UTF-16BE by NUL heuristic', async () => { + const file = new Blob([encodeUtf16(ENV, false, false)]); + expect(parse(await readEnvFile(file))).toEqual(EXPECTED); +}); + +test('keeps UTF-8 text containing a stray NUL as UTF-8', async () => { + const text = 'A=1\nB=has\u0000nul'; + const file = new Blob([new TextEncoder().encode(text)]); + expect(await readEnvFile(file)).toBe(text); +}); diff --git a/src/lib/helpers/envfile.ts b/src/lib/helpers/envfile.ts index 637e852737..f445625823 100644 --- a/src/lib/helpers/envfile.ts +++ b/src/lib/helpers/envfile.ts @@ -15,3 +15,47 @@ export function parse(src: string): Data { } return result; } + +/** + * Reads an uploaded .env file as text, honoring its encoding. + * + * `File.text()` always decodes UTF-8, but .env files written on Windows are + * often UTF-16 (PowerShell's `>` redirect defaults to it). Decoded as UTF-8, + * every character in such a file gains an interleaved NUL byte, so a key + * like SOME_API_KEY is stored with a NUL after every letter - an + * invalid env var name that the API now refuses. Detect UTF-16 by BOM, or by + * interleaved NUL bytes when the BOM is missing, and decode accordingly. + */ +export async function readEnvFile(file: Blob): Promise { + const buffer = new Uint8Array(await file.arrayBuffer()); + + let encoding = 'utf-8'; + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + encoding = 'utf-16le'; + } else if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { + encoding = 'utf-16be'; + } else if (buffer.length >= 2) { + // No BOM: ASCII-range text stored as UTF-16 has a NUL in every code + // unit's high byte. Its position tells the byte order apart. + let evenNuls = 0; + let oddNuls = 0; + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] === 0) { + if (i % 2 === 0) { + evenNuls++; + } else { + oddNuls++; + } + } + } + const units = buffer.length / 2; + if (oddNuls > units * 0.7) { + encoding = 'utf-16le'; + } else if (evenNuls > units * 0.7) { + encoding = 'utf-16be'; + } + } + + // TextDecoder strips the BOM for both UTF-8 and UTF-16. + return new TextDecoder(encoding).decode(buffer); +} diff --git a/src/routes/(console)/project-[region]-[project]/uploadVariablesModal.svelte b/src/routes/(console)/project-[region]-[project]/uploadVariablesModal.svelte index e2d10734c0..a1d491c6b2 100644 --- a/src/routes/(console)/project-[region]-[project]/uploadVariablesModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/uploadVariablesModal.svelte @@ -14,7 +14,7 @@ Typography, Upload } from '@appwrite.io/pink-svelte'; - import { parse } from '$lib/helpers/envfile'; + import { parse, readEnvFile } from '$lib/helpers/envfile'; import { removeFile } from '$lib/helpers/files'; import { validateVariables } from '$lib/helpers/variables'; import type { VariablesOperationItem } from './variablesOperation'; @@ -57,7 +57,7 @@ throw new Error('No file selected'); } - const uploaded = parse(await files[0].text()); + const uploaded = parse(await readEnvFile(files[0])); if (!Object.keys(uploaded).length) { throw new Error('No variables found');