From d7efcd02e915d3bcc507ec52a582cc7744c9655a Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:22:45 +0200 Subject: [PATCH 1/2] fix: decode UTF-16 .env files on variable import 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 gains an interleaved NUL byte, so an imported key was stored with a NUL after every letter - an invalid env var name that broke every subsequent deployment for the resource. readEnvFile() detects UTF-16 by BOM, or by the interleaved-NUL pattern when the BOM is missing, and decodes accordingly; both import modals now use it instead of File.text(). --- .../variables/importVariablesModal.svelte | 4 +- src/lib/helpers/envfile.test.ts | 54 +++++++++++++++++++ src/lib/helpers/envfile.ts | 44 +++++++++++++++ .../uploadVariablesModal.svelte | 4 +- 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/lib/helpers/envfile.test.ts 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..4fcb1b3406 --- /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): Uint8Array { + const codeUnits = bom + ? [0xfeff, ...text.split('').map((c) => c.charCodeAt(0))] + : text.split('').map((c) => c.charCodeAt(0)); + const bytes = new Uint8Array(codeUnits.length * 2); + const view = new DataView(bytes.buffer); + codeUnits.forEach((unit, i) => view.setUint16(i * 2, unit, littleEndian)); + return bytes; +} + +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'); From 34e8aa5ded9b912c850a6c5a1bc6c18765e44cbc Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:21:40 +0200 Subject: [PATCH 2/2] test: build UTF-16 fixtures as ArrayBuffer for BlobPart typing --- src/lib/helpers/envfile.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/helpers/envfile.test.ts b/src/lib/helpers/envfile.test.ts index 4fcb1b3406..9a984d722b 100644 --- a/src/lib/helpers/envfile.test.ts +++ b/src/lib/helpers/envfile.test.ts @@ -1,14 +1,14 @@ import { parse, readEnvFile } from '$lib/helpers/envfile'; import { expect, test } from 'vitest'; -function encodeUtf16(text: string, littleEndian: boolean, bom: boolean): Uint8Array { +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 bytes = new Uint8Array(codeUnits.length * 2); - const view = new DataView(bytes.buffer); + const buffer = new ArrayBuffer(codeUnits.length * 2); + const view = new DataView(buffer); codeUnits.forEach((unit, i) => view.setUint16(i * 2, unit, littleEndian)); - return bytes; + return buffer; } const ENV = 'ACME_SERVICE_API_KEY=secret-value\nOTHER_KEY=other';