Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/lib/components/variables/importVariablesModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand Down
54 changes: 54 additions & 0 deletions src/lib/helpers/envfile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
44 changes: 44 additions & 0 deletions src/lib/helpers/envfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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';
}
Comment on lines +51 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 UTF-16 heuristic misses Unicode

When a BOM-less UTF-16 file contains at least 30% non-ASCII code units in its values or comments, the file-wide NUL ratio falls below this threshold and readEnvFile decodes it as UTF-8. This leaves interleaved NULs in ASCII keys, causing variable validation to reject the entire import.

Fix in Claude Code Fix in Codex

}

// TextDecoder strips the BOM for both UTF-8 and UTF-16.
return new TextDecoder(encoding).decode(buffer);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down