Files
fluxer/fluxer_desktop/src/main/LocalAppFileResolver.test.mjs
T

539 lines
22 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {Buffer} from 'node:buffer';
import {mkdirSync, mkdtempSync, symlinkSync, writeFileSync} from 'node:fs';
import fsPromises, {rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {after, describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {DesktopLocalAppFiles, LocalAppResourceBudget} = await import('./LocalAppFileResolver.ts');
const REACTION_BURST_SIZE = 300;
const REWRITTEN_FILE_MAX_BYTES = 4 * 1024 * 1024;
const OPEN_SHELL_RESPONSES = 16;
const WAIT_GIVE_UP_MS = 250;
const LARGE_ASSET_BODY = 'x'.repeat(512 * 1024);
const temporaryRoots = [];
function createRoot(prefix) {
const root = mkdtempSync(path.join(os.tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
function createRendererRoot() {
const root = createRoot('local-app-renderer-');
mkdirSync(path.join(root, 'assets'));
mkdirSync(path.join(root, 'web'));
writeFileSync(path.join(root, 'index.html'), '<html lang="en"><head></head><body></body></html>');
writeFileSync(path.join(root, 'assets', 'deadbeefdeadbeef.js'), 'console.log(1)');
writeFileSync(path.join(root, 'assets', 'cafed00dcafed00d.css'), '');
writeFileSync(path.join(root, 'assets', 'f00dfacef00dface.js'), LARGE_ASSET_BODY);
writeFileSync(path.join(root, 'web', 'favicon-32x32.png'), 'png-bytes');
writeFileSync(path.join(root, 'manifest.json'), '{}');
return root;
}
const rendererRoot = createRendererRoot();
const outsideRoot = createRoot('local-app-outside-');
writeFileSync(path.join(outsideRoot, 'secret.txt'), 'secret');
symlinkSync(path.join(outsideRoot, 'secret.txt'), path.join(rendererRoot, 'escape.txt'));
const files = new DesktopLocalAppFiles({rendererRoot});
function resolvePath(pathname) {
return files.resolve(`fluxer-app://app${pathname}`);
}
function neverAborted() {
return new AbortController().signal;
}
async function drain(stream) {
let text = '';
for await (const chunk of stream) {
text += Buffer.from(chunk).toString('utf8');
}
return text;
}
function tick() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
const STILL_WAITING = Symbol('still-waiting');
async function settleOrGiveUp(promise) {
let timer;
const gaveUp = new Promise((resolve) => {
timer = setTimeout(() => resolve(STILL_WAITING), WAIT_GIVE_UP_MS);
});
promise.catch(() => {});
try {
return await Promise.race([promise, gaveUp]);
} finally {
clearTimeout(timer);
}
}
after(async () => {
for (const root of temporaryRoots) {
await rm(root, {recursive: true, force: true});
}
});
describe('renderer file resolution', () => {
test('extensionless routes fall back to index.html', async () => {
for (const pathname of ['/', '/channels/@me', '/login', '/settings/user']) {
const resolved = await resolvePath(pathname);
assert.equal(resolved.filePath, path.join(rendererRoot, 'index.html'), pathname);
}
});
test('a dotted route segment still falls back to index.html, not 404', async () => {
for (const pathname of ['/invite/abc.def', '/users/1.2.3', '/theme/my.custom.theme']) {
const resolved = await resolvePath(pathname);
assert.equal(resolved.filePath, path.join(rendererRoot, 'index.html'), pathname);
}
});
test('a present asset resolves to itself with the right metadata', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
assert.equal(resolved.type, 'file');
assert.equal(resolved.filePath, path.join(rendererRoot, 'assets', 'deadbeefdeadbeef.js'));
assert.equal(resolved.size, 14);
assert.equal(resolved.contentType, 'application/javascript; charset=utf-8');
assert.equal(resolved.cacheControl, 'public, max-age=31536000, immutable');
});
test('a missing asset is 404, never the SPA shell', async () => {
const resolved = await resolvePath('/assets/missing.js');
assert.equal(resolved.type, 'not-found');
});
});
describe('renderer file resolution refuses to leave the bundle', () => {
test('path escapes in every encoding are blocked', async () => {
const escapes = [
'/..%2f..%2fetc%2fpasswd',
'/%2e%2e%2f%2e%2e%2fx',
'/..\\..\\x',
'/assets/..%5c..%5cx',
'/x%00.js',
'/C:\\x',
'/%ZZ',
];
for (const pathname of escapes) {
const resolved = await resolvePath(pathname);
assert.equal(resolved.type, 'blocked', `${pathname} -> ${JSON.stringify(resolved)}`);
}
});
test('a dot-segment path is normalised by the URL parser before it can escape', async () => {
const resolved = await resolvePath('/../etc/passwd');
assert.equal(resolved.filePath, path.join(rendererRoot, 'index.html'));
});
test('a symlink pointing outside the bundle is not served', async () => {
const resolved = await resolvePath('/escape.txt');
assert.equal(resolved.type, 'not-found');
});
test('a request that is not on the local app origin is blocked', async () => {
assert.equal((await files.resolve('https://web.fluxer.app/')).reason, 'invalid-origin');
assert.equal((await files.resolve('data:text/html,x')).reason, 'invalid-origin');
assert.equal((await files.resolve('fluxer-app://evil/')).reason, 'invalid-origin');
assert.equal((await files.resolve('fluxer-app://app:8080/')).reason, 'invalid-origin');
});
});
describe('renderer file reads', () => {
test('a served asset comes back byte for byte', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
const stream = await files.openStream(resolved.filePath, resolved.size, neverAborted());
assert.equal(await drain(stream), 'console.log(1)');
});
test('an asset too large to buffer is streamed and still comes back whole', async () => {
const resolved = await resolvePath('/assets/f00dfacef00dface.js');
assert.equal(resolved.size, LARGE_ASSET_BODY.length);
const stream = await files.openStream(resolved.filePath, resolved.size, neverAborted());
assert.equal(await drain(stream), LARGE_ASSET_BODY);
});
test('an empty asset is an empty body, not a failure', async () => {
const resolved = await resolvePath('/assets/cafed00dcafed00d.css');
assert.equal(resolved.size, 0);
const stream = await files.openStream(resolved.filePath, resolved.size, neverAborted());
assert.equal(await drain(stream), '');
});
test('readForResponse hands back the bytes and enforces the size ceiling', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
const file = await files.readForResponse(resolved.filePath, 1024, neverAborted());
try {
assert.equal(file.contents.toString('utf8'), 'console.log(1)');
} finally {
file.release();
}
await assert.rejects(() => files.readForResponse(resolved.filePath, 4, neverAborted()), /exceeds 4 bytes/u);
});
test('openStream streams the file and refuses a size that changed under it', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
const controller = new AbortController();
const stream = await files.openStream(resolved.filePath, resolved.size, controller.signal);
assert.equal(await drain(stream), 'console.log(1)');
await assert.rejects(
() => files.openStream(resolved.filePath, resolved.size + 1, controller.signal),
(error) => error.name === 'LocalRendererFileChangedDuringOpenError',
);
controller.abort();
await assert.rejects(() => files.openStream(resolved.filePath, resolved.size, controller.signal));
});
test('a buffered body the renderer never reads is torn down when the request aborts', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
const controller = new AbortController();
const stream = await files.openStream(resolved.filePath, resolved.size, controller.signal);
controller.abort(new Error('renderer went away'));
await assert.rejects(() => drain(stream), /renderer went away/u);
});
test('isIndexFile only matches the shell document', async () => {
assert.equal(files.isIndexFile(path.join(rendererRoot, 'index.html')), true);
assert.equal(files.isIndexFile(path.join(rendererRoot, 'manifest.json')), false);
});
test('a symlinked path handed straight to a read never yields what it points at', async () => {
const symlinkPath = path.join(rendererRoot, 'escape.txt');
await assert.rejects(
() => files.readForResponse(symlinkPath, 1024, neverAborted()),
(error) => error.code === 'ELOOP',
'readForResponse followed a symlink out of the bundle',
);
await assert.rejects(
() => files.openStream(symlinkPath, 'secret'.length, neverAborted()),
(error) => error.code === 'ELOOP',
'openStream followed a symlink out of the bundle',
);
});
});
describe('a reaction burst is served rather than refused', () => {
test('every request of a burst far past the old in-flight ceiling gets its bytes', async () => {
const resolved = await resolvePath('/assets/deadbeefdeadbeef.js');
const signal = neverAborted();
const streams = await Promise.all(
Array.from({length: REACTION_BURST_SIZE}, () => files.openStream(resolved.filePath, resolved.size, signal)),
);
const bodies = await Promise.all(streams.map((stream) => drain(stream)));
assert.deepEqual(bodies, new Array(REACTION_BURST_SIZE).fill('console.log(1)'));
});
test('a burst of shell rewrites is served one after another instead of being turned away', async () => {
const resolved = await resolvePath('/index.html');
const signal = neverAborted();
const bodies = await Promise.all(
Array.from({length: 32}, async () => {
const file = await files.readForResponse(resolved.filePath, REWRITTEN_FILE_MAX_BYTES, signal);
try {
return file.contents.toString('utf8');
} finally {
file.release();
}
}),
);
assert.deepEqual(bodies, new Array(32).fill('<html lang="en"><head></head><body></body></html>'));
});
test('a shell rewrite never waits on shell responses that are still open', async () => {
const resolved = await resolvePath('/index.html');
const signal = neverAborted();
const open = [];
try {
for (let index = 0; index < OPEN_SHELL_RESPONSES; index += 1) {
const file = await settleOrGiveUp(files.readForResponse(resolved.filePath, REWRITTEN_FILE_MAX_BYTES, signal));
assert.notEqual(file, STILL_WAITING, `shell response ${index} was made to wait on the ones before it`);
open.push(file);
}
} finally {
for (const file of open) {
file.release();
}
}
assert.equal(open.length, OPEN_SHELL_RESPONSES);
});
test('a shell document past the rewrite ceiling still fails loudly', async () => {
const resolved = await resolvePath('/index.html');
await assert.rejects(
() => files.readForResponse(resolved.filePath, 4, neverAborted()),
(error) => error.name === 'LocalRendererFileTooLargeError',
);
});
});
describe('the resource budget queues instead of refusing', () => {
test('a burst is admitted in order and never exceeds the limit', async () => {
const budget = new LocalAppResourceBudget(2);
const signal = neverAborted();
const admitted = [];
let peak = 0;
await Promise.all(
Array.from({length: 10}, (_unused, index) =>
budget.acquire(1, signal).then(async (permit) => {
admitted.push(index);
peak = Math.max(peak, budget.inUse);
await tick();
permit.release();
}),
),
);
assert.deepEqual(admitted, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.equal(peak, 2);
assert.equal(budget.inUse, 0);
assert.equal(budget.waitingCount, 0);
});
test('a reservation larger than the whole budget still runs once the budget frees up', async () => {
const budget = new LocalAppResourceBudget(4);
const signal = neverAborted();
const held = await budget.acquire(4, signal);
const oversized = budget.acquire(16, signal);
held.release();
const permit = await oversized;
assert.equal(budget.inUse, 4);
permit.release();
assert.equal(budget.inUse, 0);
});
test('a waiter that aborts leaves the queue and reserves nothing', async () => {
const budget = new LocalAppResourceBudget(1);
const held = await budget.acquire(1, neverAborted());
const controller = new AbortController();
const queued = budget.acquire(1, controller.signal);
assert.equal(budget.waitingCount, 1);
controller.abort(new Error('renderer went away'));
await assert.rejects(() => queued, /renderer went away/u);
assert.equal(budget.waitingCount, 0);
assert.equal(budget.inUse, 1);
held.release();
assert.equal(budget.inUse, 0);
});
test('a waiter aborted in the same turn it is admitted hands its permit straight back', async () => {
const budget = new LocalAppResourceBudget(1);
const held = await budget.acquire(1, neverAborted());
const controller = new AbortController();
controller.signal.addEventListener('abort', () => held.release());
const queued = budget.acquire(1, controller.signal);
controller.abort(new Error('renderer went away'));
await assert.rejects(() => queued, /renderer went away/u);
assert.equal(budget.inUse, 0);
assert.equal(budget.waitingCount, 0);
const permit = await budget.acquire(1, neverAborted());
assert.equal(budget.inUse, 1);
permit.release();
});
test('an acquire that is already aborted never joins the queue', async () => {
const budget = new LocalAppResourceBudget(1);
const controller = new AbortController();
controller.abort(new Error('gone'));
await assert.rejects(() => budget.acquire(1, controller.signal), /gone/u);
assert.equal(budget.inUse, 0);
assert.equal(budget.waitingCount, 0);
});
test('a waiter that aborts wakes the waiter queued behind it', async () => {
const budget = new LocalAppResourceBudget(10);
const kept = await budget.acquire(5, neverAborted());
const released = await budget.acquire(5, neverAborted());
const controller = new AbortController();
const blocked = budget.acquire(8, controller.signal);
const successor = budget.acquire(4, neverAborted());
released.release();
assert.equal(budget.waitingCount, 2);
controller.abort(new Error('renderer went away'));
await assert.rejects(() => blocked, /renderer went away/u);
const permit = await settleOrGiveUp(successor);
assert.notEqual(permit, STILL_WAITING, 'the successor was never woken after the aborted waiter left');
assert.equal(budget.inUse, 9);
permit.release();
kept.release();
assert.equal(budget.inUse, 0);
assert.equal(budget.waitingCount, 0);
});
test('a nonsensical reservation is a real error, not a capacity refusal', async () => {
const budget = new LocalAppResourceBudget(4);
await assert.rejects(() => budget.acquire(0, neverAborted()), /not a positive integer/u);
await assert.rejects(() => budget.acquire(1.5, neverAborted()), /not a positive integer/u);
});
});
describe('a renderer root with no index.html', () => {
test('rejects with MissingLocalRendererIndexError, and the rejection is memoised', async () => {
const emptyRoot = createRoot('local-app-empty-');
const emptyFiles = new DesktopLocalAppFiles({rendererRoot: emptyRoot});
const isMissingIndex = (error) => error.name === 'MissingLocalRendererIndexError';
await assert.rejects(() => emptyFiles.resolve('fluxer-app://app/'), isMissingIndex);
writeFileSync(path.join(emptyRoot, 'index.html'), '<html lang="en"></html>');
await assert.rejects(() => emptyFiles.resolve('fluxer-app://app/'), isMissingIndex);
});
});
describe('a renderer root probe that fails', () => {
test('is retried on the next request instead of poisoning every later one', async () => {
const root = createRendererRoot();
const indexPath = path.join(root, 'index.html');
const probed = new DesktopLocalAppFiles({rendererRoot: root});
const lstat = fsPromises.lstat;
let failed = false;
fsPromises.lstat = async (target, ...rest) => {
if (target === indexPath && !failed) {
failed = true;
throw Object.assign(new Error('too many open files'), {code: 'EMFILE'});
}
return await lstat(target, ...rest);
};
try {
await assert.rejects(
() => probed.resolve('fluxer-app://app/channels/@me'),
(error) => error.code === 'EMFILE',
);
const resolved = await probed.resolve('fluxer-app://app/channels/@me');
assert.equal(resolved.filePath, indexPath);
} finally {
fsPromises.lstat = lstat;
}
});
});
const moduleStoreRoot = createRoot('local-app-modules-');
const moduleDirectory = path.join(moduleStoreRoot, 'store', 'fluxer_renderer', 'b'.repeat(64));
mkdirSync(path.join(moduleDirectory, 'assets'), {recursive: true});
writeFileSync(path.join(moduleDirectory, 'assets', 'cafebabecafebabe.wasm'), 'wasm-bytes');
writeFileSync(path.join(moduleDirectory, 'assets', 'deadbeefdeadbeef.js'), 'console.log(2)');
writeFileSync(path.join(moduleDirectory, 'index.html'), '<html lang="en"><body>module</body></html>');
symlinkSync(path.join(outsideRoot, 'secret.txt'), path.join(moduleDirectory, 'assets', 'aaaaaaaabbbbbbbb.css'));
const siblingStoreRoot = `${moduleStoreRoot}-sibling`;
temporaryRoots.push(siblingStoreRoot);
mkdirSync(siblingStoreRoot);
writeFileSync(path.join(siblingStoreRoot, 'secret.txt'), 'sibling-secret');
function moduleIndexEntries() {
return [
['assets/cafebabecafebabe.wasm', path.join(moduleDirectory, 'assets', 'cafebabecafebabe.wasm')],
['assets/deadbeefdeadbeef.js', path.join(moduleDirectory, 'assets', 'deadbeefdeadbeef.js')],
['assets/aaaaaaaabbbbbbbb.css', path.join(moduleDirectory, 'assets', 'aaaaaaaabbbbbbbb.css')],
['index.html', path.join(moduleDirectory, 'index.html')],
];
}
function createModuleFiles(entries = moduleIndexEntries()) {
const moduleFiles = new DesktopLocalAppFiles({rendererRoot: createRendererRoot()});
moduleFiles.setModuleIndex({root: moduleStoreRoot, files: new Map(entries)});
return moduleFiles;
}
describe('the module root chain', () => {
test('a committed module file wins over the asar copy', async () => {
const moduleFiles = createModuleFiles();
const resolved = await moduleFiles.resolve('fluxer-app://app/assets/deadbeefdeadbeef.js');
assert.equal(resolved.type, 'file');
assert.equal(resolved.filePath, path.join(moduleDirectory, 'assets', 'deadbeefdeadbeef.js'));
assert.equal(resolved.size, 14);
assert.equal(resolved.contentType, 'application/javascript; charset=utf-8');
const stream = await moduleFiles.openStream(resolved.filePath, resolved.size, neverAborted());
assert.equal(await drain(stream), 'console.log(2)');
});
test('a store asset is cached from its resolved path, not its request path', async () => {
const moduleFiles = createModuleFiles();
const resolved = await moduleFiles.resolve('fluxer-app://app/assets/cafebabecafebabe.wasm');
assert.equal(resolved.filePath, path.join(moduleDirectory, 'assets', 'cafebabecafebabe.wasm'));
assert.equal(resolved.cacheControl, 'public, max-age=31536000, immutable');
assert.equal(resolved.contentType, 'application/wasm');
assert.equal(resolved.size, 10);
});
test('a missing module asset is 404, never the SPA shell', async () => {
const moduleFiles = createModuleFiles();
for (const pathname of ['/assets/missing.wasm', '/assets/missing.js', '/assets/missing.woff2']) {
const resolved = await moduleFiles.resolve(`fluxer-app://app${pathname}`);
assert.equal(resolved.type, 'not-found', pathname);
assert.equal(resolved.filePath, undefined, pathname);
}
});
test('a module entry whose file is gone falls back to the asar copy', async () => {
const rendererRoot = createRendererRoot();
const moduleFiles = new DesktopLocalAppFiles({rendererRoot});
moduleFiles.setModuleIndex({
root: moduleStoreRoot,
files: new Map([['assets/deadbeefdeadbeef.js', path.join(moduleDirectory, 'assets', 'ghost.js')]]),
});
const resolved = await moduleFiles.resolve('fluxer-app://app/assets/deadbeefdeadbeef.js');
assert.equal(resolved.filePath, path.join(rendererRoot, 'assets', 'deadbeefdeadbeef.js'));
assert.equal(resolved.size, 14);
});
test('a module entry pointing outside the store root is blocked', async () => {
const moduleFiles = createModuleFiles([['evil.txt', path.join(outsideRoot, 'secret.txt')]]);
const resolved = await moduleFiles.resolve('fluxer-app://app/evil.txt');
assert.equal(resolved.type, 'blocked');
assert.equal(resolved.reason, 'path-escape');
});
test('a module entry in a sibling directory that only shares the store root name prefix is blocked', async () => {
const moduleFiles = createModuleFiles([['evil.txt', path.join(siblingStoreRoot, 'secret.txt')]]);
const resolved = await moduleFiles.resolve('fluxer-app://app/evil.txt');
assert.equal(resolved.type, 'blocked');
assert.equal(resolved.reason, 'path-escape');
});
test('a symlinked module file is not served and does not become the SPA shell', async () => {
const moduleFiles = createModuleFiles();
const resolved = await moduleFiles.resolve('fluxer-app://app/assets/aaaaaaaabbbbbbbb.css');
assert.equal(resolved.type, 'not-found');
});
test('request path hardening still runs when an index is installed', async () => {
const moduleFiles = createModuleFiles();
for (const pathname of ['/x%00.js', '/..%2f..%2fetc%2fpasswd', '/assets/..%5c..%5cx']) {
const resolved = await moduleFiles.resolve(`fluxer-app://app${pathname}`);
assert.equal(resolved.type, 'blocked', pathname);
}
});
test('the committed module supplies index.html for the shell and for SPA routes', async () => {
const moduleFiles = createModuleFiles();
for (const pathname of ['/', '/index.html', '/channels/@me', '/invite/abc.def']) {
const resolved = await moduleFiles.resolve(`fluxer-app://app${pathname}`);
assert.equal(resolved.filePath, path.join(moduleDirectory, 'index.html'), pathname);
}
});
test('clearing the module index restores the asar chain', async () => {
const rendererRoot = createRendererRoot();
const moduleFiles = new DesktopLocalAppFiles({rendererRoot});
moduleFiles.setModuleIndex({root: moduleStoreRoot, files: new Map(moduleIndexEntries())});
moduleFiles.setModuleIndex(null);
const resolved = await moduleFiles.resolve('fluxer-app://app/assets/deadbeefdeadbeef.js');
assert.equal(resolved.filePath, path.join(rendererRoot, 'assets', 'deadbeefdeadbeef.js'));
const route = await moduleFiles.resolve('fluxer-app://app/channels/@me');
assert.equal(route.filePath, path.join(rendererRoot, 'index.html'));
});
});