Skip to content
Merged
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
27 changes: 27 additions & 0 deletions src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ import { Logger } from '../types';
const JAVASCRIPT_DTS_URL =
'https://raw.githubusercontent.com/ioBroker/ioBroker.javascript/refs/heads/master/src/lib/javascript.d.ts';

/**
* Upper bound for the download. The real declaration file is well under 200 KB;
* anything past this is not it, and an unbounded response body has no business
* being buffered and then written into someone's project.
*/
const MAX_DTS_BYTES = 2 * 1024 * 1024;

/**
* A captive portal, a corporate proxy, or a GitHub error page answers 200 with HTML,
* and `res.ok` cannot tell that from the real file. Writing a login page over a
* working javascript.d.ts breaks every script in the editor and presents as a
* TypeScript problem, so check what arrived before it goes near the disk.
*/
function looksLikeDeclarationFile(text: string): boolean {
return /^\s*declare\s/m.test(text);
}

const GLOBAL_DTS_CONTENT = `export {};
declare global {
function require(library: string): any;
Expand Down Expand Up @@ -122,7 +139,17 @@ export async function setupTypes(
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const declared = Number(res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_DTS_BYTES) {
throw new Error(`the response announces ${Math.round(declared / 1024)} KB`);
}
const text = await res.text();
if (text.length > MAX_DTS_BYTES) {
throw new Error(`the response is ${Math.round(text.length / 1024)} KB`);
}
if (!looksLikeDeclarationFile(text)) {
throw new Error('the response is not a TypeScript declaration file');
}
await fs.writeFile(dtsPath, text, 'utf8');
log.info(`Downloaded javascript.d.ts (${Math.round(text.length / 1024)} KB)`);
} catch (err) {
Expand Down
78 changes: 77 additions & 1 deletion test/commands-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* module semantics per file (no phantom name collisions) and a module target that
* permits top-level await.
*
* The download is not exercised — it needs the network. `--offline` covers the rest.
* The download is exercised against a stubbed `fetch` rather than the network: what
* matters is that a response which is not the declaration file never reaches the disk.
* `--offline` covers the rest.
*/

import { describe, it } from 'node:test';
Expand All @@ -22,6 +24,19 @@ async function readTsconfig(root: string, scriptRoot: string): Promise<Record<st
return JSON.parse(raw) as Record<string, never>;
}

/** Run `fn` with `fetch` replaced by one that answers every call with `res`. */
async function withStubbedFetch(res: Response, fn: () => Promise<void>): Promise<void> {
const real = globalThis.fetch;
globalThis.fetch = () => Promise.resolve(res.clone());
try {
await fn();
} finally {
globalThis.fetch = real;
}
}

const DTS_PATH = ['.iobroker', 'types', 'javascript.d.ts'];

describe('types', () => {
it('writes a config that gives each script its own scope', async () => {
const project = await makeTempProject();
Expand Down Expand Up @@ -96,6 +111,67 @@ describe('types', () => {
}
});

it('writes the declaration file when the download is the real thing', async () => {
const project = await makeTempProject();
try {
const dts = 'declare global {\n function log(msg: string): void;\n}\n';
const { log } = makeCapturingLogger();
await withStubbedFetch(new Response(dts, { status: 200 }), async () => {
await setupTypes(project.root, 'scripts', {}, log);
});

assert.equal(await fs.readFile(path.join(project.root, ...DTS_PATH), 'utf8'), dts);
} finally {
await project.cleanup();
}
});

it('leaves a working copy alone when a portal answers 200 with a login page', async () => {
const project = await makeTempProject();
try {
const good = 'declare global {\n function log(msg: string): void;\n}\n';
const dtsPath = path.join(project.root, ...DTS_PATH);
await fs.mkdir(path.dirname(dtsPath), { recursive: true });
await fs.writeFile(dtsPath, good, 'utf8');

const portal = '<!doctype html><html><body>Sign in to continue</body></html>';
const { log, captured } = makeCapturingLogger();
await withStubbedFetch(new Response(portal, { status: 200 }), async () => {
await setupTypes(project.root, 'scripts', {}, log);
});

assert.equal(await fs.readFile(dtsPath, 'utf8'), good, 'the HTML must not overwrite it');
assert.ok(
captured.warn.some((l) => /not a TypeScript declaration file/i.test(l)),
`expected a warning about the response, got ${JSON.stringify(captured.warn)}`,
);
} finally {
await project.cleanup();
}
});

it('refuses a body far too large to be the declaration file', async () => {
const project = await makeTempProject();
try {
const huge = `declare global {}\n${'x'.repeat(3 * 1024 * 1024)}`;
const { log, captured } = makeCapturingLogger();
await withStubbedFetch(new Response(huge, { status: 200 }), async () => {
await setupTypes(project.root, 'scripts', {}, log);
});

await assert.rejects(
() => fs.access(path.join(project.root, ...DTS_PATH)),
'nothing should have been written',
);
assert.ok(
captured.warn.some((l) => l.includes('KB')),
`expected a warning naming the size, got ${JSON.stringify(captured.warn)}`,
);
} finally {
await project.cleanup();
}
});

it('says what is missing rather than failing when offline and nothing is cached', async () => {
const project = await makeTempProject();
try {
Expand Down
8 changes: 8 additions & 0 deletions test/fake-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,20 @@ export interface RecordedRequest {
body: string;
}

/**
* Keys that must never be copied by `deepMerge`. `src` arrives as parsed JSON off the
* websocket, so an object id like `__proto__` would otherwise reach through the merge
* and rewrite the prototype instead of setting a property.
*/
const UNSAFE_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

/** Deep-merge `src` into `dest`, mirroring the real server's `extendObject` behaviour. */
function deepMerge(
dest: Record<string, unknown>,
src: Record<string, unknown>,
): Record<string, unknown> {
for (const key of Object.keys(src)) {
if (UNSAFE_MERGE_KEYS.has(key)) continue;
const srcVal = src[key];
const destVal = dest[key];
if (
Expand Down