From 22606679fa79ea11d1f8f44ed38472be9368bd2b 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] 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 a key like VITE_GEMINI_API_KEY was stored as V\u0000I\u0000T\u0000E... - 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 | Bin 505 -> 2207 bytes .../uploadVariablesModal.svelte | 4 +- 4 files changed, 58 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..18194cdab8 --- /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 = 'VITE_GEMINI_API_KEY=secret-value\nOTHER_KEY=other'; +const EXPECTED = { VITE_GEMINI_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 637e852737b93f32887a4adea77196a2421aea07..78abfb1b7442fa97ba7e1e90cbe02ce07b3ff664 100644 GIT binary patch literal 2207 zcmb7F+iu%N5cRXZV&I|?W!a?dht!ZG7j~l*LL0}Z?G%OT*eh}k0OqJ&gu2)WVI9u&^aY%fVB(iPP>PW3by zWEaB!$??iIuT<)!RxlIBVq{I) zYm7R*fbq}*dQz>d1B13IU31ZghsWcM%Tg+|6p}Dvr!RfPs1w^+DP7e5SG7CAI@boG zfX6PNbMUSm2%*;DroyEKA@X(XU!vq)G&H!FY97wenOK*6zYZm zJWwc{FCTrw!mmMB8P(Vyw;o%qWM`)ZJ2W(rSr7`US*b;a8N@VW;30#s;PBqd=5xU@IE42=z^bRK9kp=(a>Lr}EFQ|AcYvkO-`S8f%FX?|p7>hgXB! z-v_UU=fm66i{b4bgMWbXLn5)J#5nZ!+pXc&)z)C^(}$1S@ae;U)LBkMDw>VVsCRRk zk^v>A2(Az$paOxwK*9!4rt$|OHD1Pa|}nuAT3O# zwO)uqWz9T5q%2W7Eo&ZMvXrr~wMt=^EX|d7xNu#15 zuyK{=)Z;!VT);K8^yt(WQAc7Sr1K4r1s|MwyQo82@B}TnAz)2qt;n_1{6h|BF6Yu+d*uT&7jnjkGDQ;?W$=qd>gXbkSfgS-%i(b+{7!RL} zmw!Lr_=n@C+uVH{>T7`Zn`MAh1eF zM`9}Er0_J4VMr>PX)FC|cBC~D6|smlK$Od9q=FWOxm z54~pcvSRq3cq^8!OQ8=2zYu}0h+0VPeuR@vIAED 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');