mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
1436 lines
56 KiB
JavaScript
1436 lines
56 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {Buffer} from 'node:buffer';
|
|
import {createHash} from 'node:crypto';
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
symlinkSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import fsPromises, {rm} from 'node:fs/promises';
|
|
import {registerHooks} from 'node:module';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {after, describe, test} from 'node:test';
|
|
import zlib from 'node:zlib';
|
|
|
|
const DESKTOP_SRC = new URL('../', import.meta.url);
|
|
const PACKAGES = new URL('../../../packages/', import.meta.url);
|
|
|
|
registerHooks({
|
|
resolve(specifier, context, nextResolve) {
|
|
if (specifier.startsWith('@electron/')) {
|
|
return {shortCircuit: true, url: new URL(`${specifier.slice('@electron/'.length)}.ts`, DESKTOP_SRC).href};
|
|
}
|
|
if (specifier.startsWith('@fluxer/')) {
|
|
return {shortCircuit: true, url: new URL(`${specifier.slice('@fluxer/'.length)}.ts`, PACKAGES).href};
|
|
}
|
|
return nextResolve(specifier, context);
|
|
},
|
|
});
|
|
|
|
const {
|
|
MODULE_FILE_STAMPS_NAME,
|
|
MODULE_INCOMING_STORE_PREFIX,
|
|
MODULE_PACKAGE_MAX_AGE_MS,
|
|
MODULE_STATE_FILE_NAME,
|
|
MODULE_STATE_VERSION,
|
|
ModuleManifestEquivocationError,
|
|
ModuleManifestRollbackError,
|
|
ModuleStore,
|
|
ModuleStoreDownloadHashMismatchError,
|
|
ModuleStoreGarbageCollectionOrderError,
|
|
ModuleStoreInstallationMissingError,
|
|
getModuleStoreRoot,
|
|
} = await import('./ModuleStore.ts');
|
|
const {ModulePackageHashMismatchError} = await import('./ModulePackage.ts');
|
|
|
|
const BLOCK = 512;
|
|
const SHELL_VERSION = '2026.823.1';
|
|
const RELEASE_CHANNEL = 'canary';
|
|
const SOURCE_SHA = 'a'.repeat(40);
|
|
const MANIFEST_FEED = {releaseChannel: RELEASE_CHANNEL, platform: 'darwin', arch: 'arm64'};
|
|
const MANIFEST_FETCHED_AT = '2026-08-23T12:00:00.000Z';
|
|
|
|
const temporaryRoots = [];
|
|
|
|
after(async () => {
|
|
for (const root of temporaryRoots) {
|
|
await rm(root, {recursive: true, force: true});
|
|
}
|
|
});
|
|
|
|
function createUserData() {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'module-store-'));
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function sha256(data) {
|
|
return createHash('sha256').update(data).digest('hex');
|
|
}
|
|
|
|
function octal(value, width) {
|
|
return `${value.toString(8).padStart(width - 1, '0')}\0`;
|
|
}
|
|
|
|
function ustarHeader(name, size) {
|
|
const header = Buffer.alloc(BLOCK);
|
|
header.write(name, 0, 100, 'utf8');
|
|
header.write(octal(0o644, 8), 100, 8, 'ascii');
|
|
header.write(octal(0, 8), 108, 8, 'ascii');
|
|
header.write(octal(0, 8), 116, 8, 'ascii');
|
|
header.write(octal(size, 12), 124, 12, 'ascii');
|
|
header.write(octal(0, 12), 136, 12, 'ascii');
|
|
header.write('0', 156, 1, 'ascii');
|
|
header.write('ustar\0', 257, 6, 'ascii');
|
|
header.write('00', 263, 2, 'ascii');
|
|
header.fill(0x20, 148, 156);
|
|
let sum = 0;
|
|
for (const byte of header) {
|
|
sum += byte;
|
|
}
|
|
header.write(octal(sum, 8), 148, 8, 'ascii');
|
|
return header;
|
|
}
|
|
|
|
function pad(size) {
|
|
const remainder = size % BLOCK;
|
|
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK - remainder);
|
|
}
|
|
|
|
function packModule(moduleName, entries, {declaredSha256 = {}} = {}) {
|
|
const files = entries.map(([relative, body]) => ({
|
|
path: relative,
|
|
sha256: declaredSha256[relative] ?? sha256(Buffer.from(body, 'utf8')),
|
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
}));
|
|
const manifest = Buffer.from(
|
|
`${JSON.stringify(
|
|
{
|
|
module: moduleName,
|
|
build_version: SHELL_VERSION,
|
|
release_channel: RELEASE_CHANNEL,
|
|
source_sha: SOURCE_SHA,
|
|
files,
|
|
},
|
|
null,
|
|
'\t',
|
|
)}\n`,
|
|
'utf8',
|
|
);
|
|
const chunks = [ustarHeader('module.json', manifest.length), manifest, pad(manifest.length)];
|
|
for (const [relative, body] of entries) {
|
|
const payload = Buffer.from(body, 'utf8');
|
|
chunks.push(ustarHeader(`files/${relative}`, payload.length), payload, pad(payload.length));
|
|
}
|
|
chunks.push(Buffer.alloc(BLOCK * 2));
|
|
const tar = Buffer.concat(chunks);
|
|
const packed = zlib.brotliCompressSync(tar, {
|
|
params: {[zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: tar.length},
|
|
});
|
|
return {module: moduleName, packed, sha256: sha256(packed), files};
|
|
}
|
|
|
|
function streamOf(buffer) {
|
|
return {
|
|
offset: 0,
|
|
chunks: (async function* stream() {
|
|
for (let offset = 0; offset < buffer.length; offset += 4096) {
|
|
yield buffer.subarray(offset, Math.min(offset + 4096, buffer.length));
|
|
}
|
|
})(),
|
|
};
|
|
}
|
|
|
|
async function openStore(userDataPath) {
|
|
return await ModuleStore.open({
|
|
root: getModuleStoreRoot(userDataPath),
|
|
shellVersion: SHELL_VERSION,
|
|
releaseChannel: RELEASE_CHANNEL,
|
|
});
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return {promise, resolve};
|
|
}
|
|
|
|
async function markLaunched(store) {
|
|
return await store.markLaunchAttemptSucceeded(await store.recordLaunchAttempt());
|
|
}
|
|
|
|
async function install(store, packageFile) {
|
|
const installed = await store.installModule({
|
|
module: packageFile.module,
|
|
sha256: packageFile.sha256,
|
|
download: () => streamOf(packageFile.packed),
|
|
});
|
|
await store.settleInstalledModules();
|
|
return installed;
|
|
}
|
|
|
|
function manifestObservation(metadataVersion, manifestSha256) {
|
|
return {feed: MANIFEST_FEED, metadataVersion, manifestSha256};
|
|
}
|
|
|
|
async function recordManifest(store, metadataVersion, manifestSha256) {
|
|
return await store.recordManifestFetch({
|
|
etag: null,
|
|
fetchedAt: MANIFEST_FETCHED_AT,
|
|
manifest: manifestObservation(metadataVersion, manifestSha256),
|
|
});
|
|
}
|
|
|
|
function readState(userDataPath) {
|
|
return JSON.parse(readFileSync(path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME), 'utf8'));
|
|
}
|
|
|
|
const RENDERER_V1 = packModule('fluxer_renderer', [
|
|
['assets/index-v1.js', 'renderer one'],
|
|
['assets/style-v1.css', 'body{}'],
|
|
]);
|
|
const RENDERER_V2 = packModule('fluxer_renderer', [['assets/index-v2.js', 'renderer two']]);
|
|
const GRAMMARS_V1 = packModule('fluxer_grammars', [['assets/rust-v1.wasm', 'grammar one']]);
|
|
const RENDERER_BAD_MEMBER = packModule('fluxer_renderer', [['assets/index-v1.js', 'renderer one']], {
|
|
declaredSha256: {'assets/index-v1.js': 'e'.repeat(64)},
|
|
});
|
|
|
|
describe('ModuleStore', () => {
|
|
test('is beside the account store, never inside it', () => {
|
|
const userDataPath = createUserData();
|
|
const root = getModuleStoreRoot(userDataPath);
|
|
assert.equal(root, path.join(userDataPath, 'modules'));
|
|
assert.equal(path.dirname(root), userDataPath);
|
|
assert.equal(existsSync(path.join(userDataPath, 'desktop-app-store.sqlite3')), false);
|
|
});
|
|
|
|
test('says when the shell changed version since the store was last opened', async () => {
|
|
const userDataPath = createUserData();
|
|
assert.equal((await openStore(userDataPath)).shellVersionChanged, false);
|
|
assert.equal((await openStore(userDataPath)).shellVersionChanged, false);
|
|
|
|
const upgraded = await ModuleStore.open({
|
|
root: getModuleStoreRoot(userDataPath),
|
|
shellVersion: '2026.824.1',
|
|
releaseChannel: RELEASE_CHANNEL,
|
|
});
|
|
|
|
assert.equal(upgraded.shellVersionChanged, true);
|
|
assert.notEqual(
|
|
readState(userDataPath).shell_version,
|
|
'2026.824.1',
|
|
'a quit or crash before the modules converge has to force the update again on the next boot',
|
|
);
|
|
const reopened = await ModuleStore.open({
|
|
root: getModuleStoreRoot(userDataPath),
|
|
shellVersion: '2026.824.1',
|
|
releaseChannel: RELEASE_CHANNEL,
|
|
});
|
|
assert.equal(reopened.shellVersionChanged, true);
|
|
|
|
await reopened.recordShellVersionConverged();
|
|
|
|
assert.equal(readState(userDataPath).shell_version, '2026.824.1');
|
|
const converged = await ModuleStore.open({
|
|
root: getModuleStoreRoot(userDataPath),
|
|
shellVersion: '2026.824.1',
|
|
releaseChannel: RELEASE_CHANNEL,
|
|
});
|
|
assert.equal(converged.shellVersionChanged, false);
|
|
});
|
|
|
|
test('writes state.json atomically and leaves no temporary behind', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await install(store, GRAMMARS_V1);
|
|
const before = readState(userDataPath);
|
|
assert.deepEqual(before.committed, {});
|
|
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
|
|
const after = readState(userDataPath);
|
|
assert.equal(after.state_version, MODULE_STATE_VERSION);
|
|
assert.deepEqual(after.committed, {
|
|
fluxer_grammars: GRAMMARS_V1.sha256,
|
|
fluxer_renderer: RENDERER_V1.sha256,
|
|
});
|
|
assert.deepEqual(after.previous, {});
|
|
assert.equal(after.boot_attempt, 0);
|
|
const leftovers = readdirSync(getModuleStoreRoot(userDataPath)).filter((name) => name.endsWith('.tmp'));
|
|
assert.deepEqual(leftovers, []);
|
|
|
|
const reopened = await openStore(userDataPath);
|
|
assert.deepEqual(reopened.getCommitted(), after.committed);
|
|
});
|
|
|
|
test('moves the old committed map to previous on the next commit', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
|
|
const state = readState(userDataPath);
|
|
assert.deepEqual(state.committed, {fluxer_renderer: RENDERER_V2.sha256});
|
|
assert.deepEqual(state.previous, {fluxer_renderer: RENDERER_V1.sha256});
|
|
});
|
|
|
|
test('a crash before the commit point leaves the old set committed', async () => {
|
|
const userDataPath = createUserData();
|
|
const first = await openStore(userDataPath);
|
|
await install(first, RENDERER_V1);
|
|
await first.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
|
|
const crashing = await openStore(userDataPath);
|
|
const staged = await install(crashing, RENDERER_V2);
|
|
assert.equal(existsSync(path.join(staged.directory, 'module.json')), true);
|
|
|
|
const rebooted = await openStore(userDataPath);
|
|
assert.deepEqual(rebooted.getCommitted(), {fluxer_renderer: RENDERER_V1.sha256});
|
|
const index = await rebooted.buildModuleIndex();
|
|
assert.deepEqual([...index.keys()].sort(), ['assets/index-v1.js', 'assets/style-v1.css']);
|
|
assert.equal(existsSync(staged.directory), true);
|
|
});
|
|
|
|
test('a download hash mismatch fails that module only and caches nothing', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await assert.rejects(
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: () => streamOf(Buffer.from('not the package', 'utf8')),
|
|
}),
|
|
ModuleStoreDownloadHashMismatchError,
|
|
);
|
|
assert.equal(existsSync(store.getPackagePath(RENDERER_V1.sha256)), false);
|
|
assert.deepEqual(readdirSync(store.incomingDownloadRoot), []);
|
|
|
|
const installed = await install(store, GRAMMARS_V1);
|
|
assert.equal(existsSync(path.join(installed.directory, 'module.json')), true);
|
|
await store.commit({fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
});
|
|
|
|
test('a member digest mismatch leaves the verified package cached for every later attempt', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
let downloads = 0;
|
|
const attempt = () =>
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_BAD_MEMBER.sha256,
|
|
download: () => {
|
|
downloads += 1;
|
|
return streamOf(RENDERER_BAD_MEMBER.packed);
|
|
},
|
|
});
|
|
const memberMismatch = (error) =>
|
|
error instanceof ModulePackageHashMismatchError && error.message === 'hash mismatch: assets/index-v1.js';
|
|
const packagePath = store.getPackagePath(RENDERER_BAD_MEMBER.sha256);
|
|
|
|
await assert.rejects(attempt(), memberMismatch);
|
|
assert.equal(downloads, 1);
|
|
assert.equal(existsSync(packagePath), true);
|
|
|
|
await assert.rejects(attempt(), memberMismatch);
|
|
assert.equal(existsSync(packagePath), true);
|
|
await assert.rejects(attempt(), memberMismatch);
|
|
|
|
assert.equal(downloads, 1);
|
|
assert.deepEqual(readdirSync(store.storeRoot), []);
|
|
});
|
|
|
|
test('a cached package whose own digest no longer matches is dropped and downloaded again', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
let downloads = 0;
|
|
const attempt = () =>
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_BAD_MEMBER.sha256,
|
|
download: () => {
|
|
downloads += 1;
|
|
return streamOf(RENDERER_BAD_MEMBER.packed);
|
|
},
|
|
});
|
|
const packagePath = store.getPackagePath(RENDERER_BAD_MEMBER.sha256);
|
|
|
|
await assert.rejects(attempt(), ModulePackageHashMismatchError);
|
|
assert.equal(downloads, 1);
|
|
writeFileSync(packagePath, 'not the package');
|
|
|
|
await assert.rejects(
|
|
attempt(),
|
|
(error) =>
|
|
error instanceof ModulePackageHashMismatchError && error.message.startsWith('package sha256 mismatch:'),
|
|
);
|
|
assert.equal(existsSync(packagePath), false);
|
|
|
|
await assert.rejects(attempt(), ModulePackageHashMismatchError);
|
|
assert.equal(downloads, 2);
|
|
assert.equal(existsSync(packagePath), true);
|
|
});
|
|
|
|
test('commit refuses when a store directory is missing', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await rm(installed.directory, {recursive: true, force: true});
|
|
|
|
await assert.rejects(store.commit({fluxer_renderer: RENDERER_V1.sha256}), ModuleStoreInstallationMissingError);
|
|
assert.deepEqual(readState(userDataPath).committed, {});
|
|
});
|
|
|
|
test('commit refuses to re-affirm a committed module whose directory was deleted', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await rm(installed.directory, {recursive: true, force: true});
|
|
|
|
await assert.rejects(store.commit({fluxer_renderer: RENDERER_V1.sha256}), ModuleStoreInstallationMissingError);
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
});
|
|
|
|
test('commit refuses when module.json is unreadable', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
writeFileSync(path.join(installed.directory, 'module.json'), 'not json');
|
|
|
|
await assert.rejects(store.commit({fluxer_renderer: RENDERER_V1.sha256}), ModuleStoreInstallationMissingError);
|
|
});
|
|
|
|
test('garbage collection only runs after a successful launch', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await assert.rejects(store.collectGarbage(), ModuleStoreGarbageCollectionOrderError);
|
|
});
|
|
|
|
test('garbage collection preserves committed and previous', async () => {
|
|
const userDataPath = createUserData();
|
|
const earlierRun = await openStore(userDataPath);
|
|
const v1 = await install(earlierRun, RENDERER_V1);
|
|
await earlierRun.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const v2 = await install(earlierRun, RENDERER_V2);
|
|
await earlierRun.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
const orphan = await install(earlierRun, GRAMMARS_V1);
|
|
const store = await openStore(userDataPath);
|
|
|
|
const incoming = path.join(store.storeRoot, `${MODULE_INCOMING_STORE_PREFIX}${'b'.repeat(64)}`);
|
|
mkdirSync(incoming, {recursive: true});
|
|
const stale = path.join(store.downloadRoot, `${'c'.repeat(64)}.br`);
|
|
writeFileSync(stale, 'stale');
|
|
const staleSeconds = (Date.now() - MODULE_PACKAGE_MAX_AGE_MS - 60_000) / 1000;
|
|
utimesSync(stale, staleSeconds, staleSeconds);
|
|
const fresh = store.getPackagePath(RENDERER_V2.sha256);
|
|
assert.equal(existsSync(fresh), true);
|
|
|
|
await markLaunched(store);
|
|
const result = await store.collectGarbage();
|
|
|
|
assert.equal(existsSync(v1.directory), true);
|
|
assert.equal(existsSync(v2.directory), true);
|
|
assert.equal(existsSync(orphan.directory), false);
|
|
assert.equal(existsSync(incoming), false);
|
|
assert.equal(existsSync(stale), false);
|
|
assert.equal(existsSync(fresh), true);
|
|
assert.equal(result.removedDirectories.includes(incoming), true);
|
|
assert.equal(result.removedPackages.includes(stale), true);
|
|
});
|
|
|
|
test('garbage collection keeps an installation that has not been committed yet', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await markLaunched(store);
|
|
const pending = await install(store, GRAMMARS_V1);
|
|
|
|
await store.collectGarbage();
|
|
|
|
assert.equal(existsSync(path.join(pending.directory, 'module.json')), true);
|
|
await store.mergeCommitted({fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.deepEqual(readState(userDataPath).committed, {
|
|
fluxer_grammars: GRAMMARS_V1.sha256,
|
|
fluxer_renderer: RENDERER_V1.sha256,
|
|
});
|
|
});
|
|
|
|
test('garbage collection waits for an in-flight install instead of deleting its staging tree', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await markLaunched(store);
|
|
|
|
const staging = path.join(store.storeRoot, `${MODULE_INCOMING_STORE_PREFIX}${GRAMMARS_V1.sha256}`);
|
|
const rename = fsPromises.rename;
|
|
const staged = deferred();
|
|
const release = deferred();
|
|
fsPromises.rename = async (from, to) => {
|
|
if (from === staging) {
|
|
staged.resolve();
|
|
await release.promise;
|
|
}
|
|
return await rename(from, to);
|
|
};
|
|
try {
|
|
const installing = install(store, GRAMMARS_V1);
|
|
await staged.promise;
|
|
const collecting = store.collectGarbage();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
assert.equal(existsSync(staging), true);
|
|
release.resolve();
|
|
const installed = await installing;
|
|
const result = await collecting;
|
|
assert.equal(existsSync(path.join(installed.directory, 'module.json')), true);
|
|
assert.equal(result.removedDirectories.includes(staging), false);
|
|
} finally {
|
|
fsPromises.rename = rename;
|
|
}
|
|
});
|
|
|
|
test('garbage collection keeps a cached package that an install is already using', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await markLaunched(store);
|
|
const reclaimed = await install(store, GRAMMARS_V1);
|
|
await rm(reclaimed.directory, {recursive: true, force: true});
|
|
const cached = store.getPackagePath(GRAMMARS_V1.sha256);
|
|
const staleSeconds = (Date.now() - MODULE_PACKAGE_MAX_AGE_MS - 60_000) / 1000;
|
|
utimesSync(cached, staleSeconds, staleSeconds);
|
|
|
|
const readdir = fsPromises.readdir;
|
|
const sweeping = deferred();
|
|
const release = deferred();
|
|
let held = false;
|
|
fsPromises.readdir = async (directory, options) => {
|
|
if (directory === store.storeRoot && !held) {
|
|
held = true;
|
|
sweeping.resolve();
|
|
await release.promise;
|
|
}
|
|
return await readdir(directory, options);
|
|
};
|
|
try {
|
|
const collecting = store.collectGarbage();
|
|
await sweeping.promise;
|
|
const installing = install(store, GRAMMARS_V1);
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
release.resolve();
|
|
const result = await collecting;
|
|
const installed = await installing;
|
|
|
|
assert.equal(existsSync(cached), true);
|
|
assert.equal(result.removedPackages.includes(cached), false);
|
|
assert.equal(existsSync(path.join(installed.directory, 'module.json')), true);
|
|
} finally {
|
|
fsPromises.readdir = readdir;
|
|
}
|
|
});
|
|
|
|
test('a second rollback onto the same set reports no rollback, so the update path still runs', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
|
|
for (let boot = 0; boot < 2; boot += 1) {
|
|
const failing = await openStore(userDataPath);
|
|
await failing.beginBootAttempt();
|
|
await failing.recordLaunchAttempt();
|
|
}
|
|
const rolledBackStore = await openStore(userDataPath);
|
|
const rollback = await rolledBackStore.beginBootAttempt();
|
|
assert.equal(rollback.rolledBack, true);
|
|
assert.deepEqual(rollback.committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
await rolledBackStore.recordLaunchAttempt();
|
|
|
|
for (let boot = 0; boot < 1; boot += 1) {
|
|
const failing = await openStore(userDataPath);
|
|
await failing.beginBootAttempt();
|
|
await failing.recordLaunchAttempt();
|
|
}
|
|
const secondRollback = await (await openStore(userDataPath)).beginBootAttempt();
|
|
|
|
assert.equal(secondRollback.rolledBack, false);
|
|
assert.equal(secondRollback.bootAttempt, 0);
|
|
assert.deepEqual(secondRollback.committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.equal(readState(userDataPath).boot_attempt, 0);
|
|
});
|
|
|
|
test('a first-ever committed set is never rolled back into an empty committed map', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
|
|
for (let boot = 0; boot < 2; boot += 1) {
|
|
const failing = await openStore(userDataPath);
|
|
await failing.beginBootAttempt();
|
|
await failing.recordLaunchAttempt();
|
|
}
|
|
const rollback = await (await openStore(userDataPath)).beginBootAttempt();
|
|
|
|
assert.equal(rollback.rolledBack, false);
|
|
assert.equal(rollback.bootAttempt, 0);
|
|
assert.deepEqual(rollback.committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
});
|
|
|
|
test('rolls back to the previous set after two failed boots', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
|
|
const firstStore = await openStore(userDataPath);
|
|
const firstBoot = await firstStore.beginBootAttempt();
|
|
assert.deepEqual(firstBoot, {
|
|
rolledBack: false,
|
|
bootAttempt: 0,
|
|
committed: {fluxer_renderer: RENDERER_V2.sha256},
|
|
});
|
|
await firstStore.recordLaunchAttempt();
|
|
assert.equal(readState(userDataPath).boot_attempt, 1);
|
|
|
|
const secondStore = await openStore(userDataPath);
|
|
const secondBoot = await secondStore.beginBootAttempt();
|
|
assert.equal(secondBoot.rolledBack, false);
|
|
assert.equal(secondBoot.bootAttempt, 1);
|
|
await secondStore.recordLaunchAttempt();
|
|
assert.equal(readState(userDataPath).boot_attempt, 2);
|
|
|
|
const thirdStore = await openStore(userDataPath);
|
|
const thirdBoot = await thirdStore.beginBootAttempt();
|
|
assert.equal(thirdBoot.rolledBack, true);
|
|
assert.equal(thirdBoot.bootAttempt, 0);
|
|
assert.deepEqual(thirdBoot.committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_renderer: RENDERER_V1.sha256});
|
|
|
|
const index = await thirdStore.buildModuleIndex();
|
|
assert.deepEqual([...index.keys()].sort(), ['assets/index-v1.js', 'assets/style-v1.css']);
|
|
|
|
await markLaunched(thirdStore);
|
|
assert.equal(readState(userDataPath).boot_attempt, 0);
|
|
const fourthBoot = await (await openStore(userDataPath)).beginBootAttempt();
|
|
assert.equal(fourthBoot.rolledBack, false);
|
|
assert.equal(fourthBoot.bootAttempt, 0);
|
|
});
|
|
|
|
test('a boot that never launches leaves the attempt counter alone, so blocking cannot downgrade anyone', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const blocked = await openStore(userDataPath);
|
|
assert.equal((await blocked.beginBootAttempt()).rolledBack, false);
|
|
}
|
|
|
|
assert.equal(readState(userDataPath).boot_attempt, 0);
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_renderer: RENDERER_V2.sha256});
|
|
});
|
|
|
|
test('the attempt counter is fsynced before the handoff, not after it', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
|
|
await store.recordLaunchAttempt();
|
|
|
|
assert.equal(readState(userDataPath).boot_attempt, 1);
|
|
assert.equal(store.getState().boot_attempt, 1);
|
|
});
|
|
|
|
test('builds an index map across every committed module', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const renderer = await install(store, RENDERER_V1);
|
|
const grammars = await install(store, GRAMMARS_V1);
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
|
|
const index = await store.buildModuleIndex();
|
|
assert.equal(index.size, 3);
|
|
assert.equal(index.get('assets/index-v1.js'), path.join(renderer.directory, 'assets', 'index-v1.js'));
|
|
assert.equal(index.get('assets/style-v1.css'), path.join(renderer.directory, 'assets', 'style-v1.css'));
|
|
assert.equal(index.get('assets/rust-v1.wasm'), path.join(grammars.directory, 'assets', 'rust-v1.wasm'));
|
|
assert.equal(index.has('assets/index-v2.js'), false);
|
|
for (const absolute of index.values()) {
|
|
assert.equal(existsSync(absolute), true);
|
|
}
|
|
assert.equal(readFileSync(index.get('assets/rust-v1.wasm'), 'utf8'), 'grammar one');
|
|
});
|
|
|
|
test('builds the index from one committed generation even when a merge lands mid-scan', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const renderer = await install(store, RENDERER_V1);
|
|
await install(store, GRAMMARS_V1);
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
|
|
const grammarsDirectory = store.getModuleDirectory('fluxer_grammars', GRAMMARS_V1.sha256);
|
|
const lstat = fsPromises.lstat;
|
|
const scanning = deferred();
|
|
const release = deferred();
|
|
let held = false;
|
|
fsPromises.lstat = async (target, ...rest) => {
|
|
if (target === grammarsDirectory && !held) {
|
|
held = true;
|
|
scanning.resolve();
|
|
await release.promise;
|
|
}
|
|
return await lstat(target, ...rest);
|
|
};
|
|
try {
|
|
const building = store.buildModuleIndex();
|
|
await scanning.promise;
|
|
await store.mergeCommitted({fluxer_renderer: RENDERER_V2.sha256});
|
|
release.resolve();
|
|
const index = await building;
|
|
|
|
assert.deepEqual([...index.keys()].sort(), ['assets/index-v1.js', 'assets/rust-v1.wasm', 'assets/style-v1.css']);
|
|
assert.equal(index.get('assets/index-v1.js'), path.join(renderer.directory, 'assets', 'index-v1.js'));
|
|
} finally {
|
|
fsPromises.lstat = lstat;
|
|
}
|
|
});
|
|
|
|
test('a served file swapped for different bytes of the same length is no longer installed', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const served = path.join(installed.directory, 'assets', 'index-v1.js');
|
|
writeFileSync(served, 'RENDERER ONE');
|
|
assert.equal(readFileSync(served).length, Buffer.byteLength('renderer one', 'utf8'));
|
|
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
await assert.rejects(store.buildModuleIndex(), ModuleStoreInstallationMissingError);
|
|
await assert.rejects(store.commit({fluxer_renderer: RENDERER_V1.sha256}), ModuleStoreInstallationMissingError);
|
|
});
|
|
|
|
test('a repair reinstall keeps the committed tree served until the replacement is renamed in', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const corrupted = path.join(installed.directory, 'assets', 'index-v1.js');
|
|
const untouched = path.join(installed.directory, 'assets', 'style-v1.css');
|
|
writeFileSync(corrupted, 'RENDERER ONE');
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
|
|
const incoming = path.join(store.storeRoot, `${MODULE_INCOMING_STORE_PREFIX}${RENDERER_V1.sha256}`);
|
|
const mkdir = fsPromises.mkdir;
|
|
const servedDuringExtraction = [];
|
|
fsPromises.mkdir = async (target, ...rest) => {
|
|
if (typeof target === 'string' && (target === incoming || target.startsWith(`${incoming}${path.sep}`))) {
|
|
servedDuringExtraction.push(existsSync(untouched));
|
|
}
|
|
return await mkdir(target, ...rest);
|
|
};
|
|
let repaired;
|
|
try {
|
|
repaired = await install(store, RENDERER_V1);
|
|
} finally {
|
|
fsPromises.mkdir = mkdir;
|
|
}
|
|
|
|
assert.equal(servedDuringExtraction.length > 0, true);
|
|
assert.deepEqual(
|
|
servedDuringExtraction.filter((present) => !present),
|
|
[],
|
|
);
|
|
assert.equal(repaired.directory, installed.directory);
|
|
assert.equal(readFileSync(corrupted, 'utf8'), 'renderer one');
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), true);
|
|
assert.deepEqual(
|
|
readdirSync(store.storeRoot).filter((name) => name.startsWith('.')),
|
|
[],
|
|
);
|
|
const index = await store.buildModuleIndex();
|
|
assert.deepEqual([...index.keys()].sort(), ['assets/index-v1.js', 'assets/style-v1.css']);
|
|
});
|
|
|
|
test('a manifest below the highest metadata_version seen for its feed is refused as a downgrade', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await recordManifest(store, 7, 'd'.repeat(64));
|
|
|
|
assert.throws(
|
|
() => store.requireManifestFresh(manifestObservation(6, 'e'.repeat(64))),
|
|
ModuleManifestRollbackError,
|
|
);
|
|
await assert.rejects(recordManifest(store, 6, 'e'.repeat(64)), ModuleManifestRollbackError);
|
|
|
|
const highWater = readState(userDataPath).manifest_high_water;
|
|
assert.equal(highWater.length, 1);
|
|
assert.equal(highWater[0].metadata_version, 7);
|
|
assert.equal(highWater[0].manifest_sha256, 'd'.repeat(64));
|
|
await recordManifest(store, 8, 'f'.repeat(64));
|
|
assert.equal(readState(userDataPath).manifest_high_water[0].metadata_version, 8);
|
|
});
|
|
|
|
test('a second manifest body at an already seen metadata_version is refused as equivocation', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await recordManifest(store, 7, 'd'.repeat(64));
|
|
|
|
assert.throws(
|
|
() => store.requireManifestFresh(manifestObservation(7, 'e'.repeat(64))),
|
|
ModuleManifestEquivocationError,
|
|
);
|
|
await assert.rejects(recordManifest(store, 7, 'e'.repeat(64)), ModuleManifestEquivocationError);
|
|
|
|
store.requireManifestFresh(manifestObservation(7, 'd'.repeat(64)));
|
|
await recordManifest(store, 7, 'd'.repeat(64));
|
|
const highWater = readState(userDataPath).manifest_high_water;
|
|
assert.equal(highWater.length, 1);
|
|
assert.equal(highWater[0].manifest_sha256, 'd'.repeat(64));
|
|
});
|
|
|
|
test('a superseded launch attempt cannot clear the boot counter that drives rollback', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const superseded = await store.recordLaunchAttempt();
|
|
const active = await store.recordLaunchAttempt();
|
|
|
|
assert.equal(await store.markLaunchAttemptSucceeded(superseded), false);
|
|
assert.equal(readState(userDataPath).boot_attempt, 2);
|
|
assert.equal(store.getState().boot_attempt, 2);
|
|
|
|
assert.equal(await store.markLaunchAttemptSucceeded(active), true);
|
|
assert.equal(readState(userDataPath).boot_attempt, 0);
|
|
});
|
|
|
|
test('a store directory whose module.json names another module is not an installation of it', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const renderer = await install(store, RENDERER_V1);
|
|
const impostor = store.getModuleDirectory('fluxer_grammars', RENDERER_V1.sha256);
|
|
mkdirSync(path.dirname(impostor), {recursive: true});
|
|
cpSync(renderer.directory, impostor, {recursive: true});
|
|
|
|
assert.equal(await store.isInstalled('fluxer_grammars', RENDERER_V1.sha256), false);
|
|
assert.equal(await store.getInstalledManifest('fluxer_grammars', RENDERER_V1.sha256), null);
|
|
await assert.rejects(store.commit({fluxer_grammars: RENDERER_V1.sha256}), ModuleStoreInstallationMissingError);
|
|
});
|
|
|
|
test('a symlinked store directory is never served, even when it points at a real installation', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const renderer = await install(store, RENDERER_V1);
|
|
const outside = path.join(userDataPath, 'outside-the-store');
|
|
cpSync(renderer.directory, outside, {recursive: true});
|
|
const linkedSha256 = 'b'.repeat(64);
|
|
const linked = store.getModuleDirectory('fluxer_renderer', linkedSha256);
|
|
symlinkSync(outside, linked, 'dir');
|
|
assert.equal(existsSync(path.join(linked, 'module.json')), true);
|
|
|
|
assert.equal(await store.isInstalled('fluxer_renderer', linkedSha256), false);
|
|
await assert.rejects(store.commit({fluxer_renderer: linkedSha256}), ModuleStoreInstallationMissingError);
|
|
});
|
|
|
|
test('an index built over a deleted committed module fails loudly', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await rm(installed.directory, {recursive: true, force: true});
|
|
|
|
await assert.rejects(store.buildModuleIndex(), ModuleStoreInstallationMissingError);
|
|
});
|
|
});
|
|
|
|
function chunksOf(buffer) {
|
|
return (async function* stream() {
|
|
for (let offset = 0; offset < buffer.length; offset += 4096) {
|
|
yield buffer.subarray(offset, Math.min(offset + 4096, buffer.length));
|
|
}
|
|
})();
|
|
}
|
|
|
|
function interruptedAfter(buffer, bytes) {
|
|
return (async function* stream() {
|
|
yield buffer.subarray(0, bytes);
|
|
throw new Error('connection reset');
|
|
})();
|
|
}
|
|
|
|
describe('ModuleStore resumable package downloads', () => {
|
|
test('an interrupted download keeps its bytes and the next attempt resumes from them', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const cut = Math.floor(RENDERER_V1.packed.length / 2);
|
|
const offsets = [];
|
|
await assert.rejects(
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: (resumeFrom) => {
|
|
offsets.push(resumeFrom);
|
|
return {offset: 0, chunks: interruptedAfter(RENDERER_V1.packed, cut)};
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(readFileSync(store.getPartialPackagePath(RENDERER_V1.sha256)).length, cut);
|
|
|
|
const installed = await store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: (resumeFrom) => {
|
|
offsets.push(resumeFrom);
|
|
return {offset: resumeFrom, chunks: chunksOf(RENDERER_V1.packed.subarray(resumeFrom))};
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(offsets, [0, cut]);
|
|
assert.equal(existsSync(path.join(installed.directory, 'module.json')), true);
|
|
assert.equal(existsSync(store.getPartialPackagePath(RENDERER_V1.sha256)), false);
|
|
assert.equal(existsSync(store.getPackagePath(RENDERER_V1.sha256)), true);
|
|
});
|
|
|
|
test('a server that ignores the range restarts the download from the first byte', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
mkdirSync(store.incomingDownloadRoot, {recursive: true});
|
|
writeFileSync(store.getPartialPackagePath(RENDERER_V1.sha256), RENDERER_V1.packed.subarray(0, 100));
|
|
|
|
const installed = await store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: () => ({offset: 0, chunks: chunksOf(RENDERER_V1.packed)}),
|
|
});
|
|
|
|
assert.equal(existsSync(path.join(installed.directory, 'module.json')), true);
|
|
});
|
|
|
|
test('a resumed partial that does not hash to the package is dropped so the retry starts clean', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
mkdirSync(store.incomingDownloadRoot, {recursive: true});
|
|
const partial = store.getPartialPackagePath(RENDERER_V1.sha256);
|
|
writeFileSync(partial, Buffer.alloc(100, 7));
|
|
|
|
await assert.rejects(
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: (resumeFrom) => ({offset: resumeFrom, chunks: chunksOf(RENDERER_V1.packed.subarray(resumeFrom))}),
|
|
}),
|
|
ModuleStoreDownloadHashMismatchError,
|
|
);
|
|
assert.equal(existsSync(partial), false);
|
|
});
|
|
|
|
test('a download that resumes at an offset nobody asked for is refused', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
|
|
await assert.rejects(
|
|
store.installModule({
|
|
module: 'fluxer_renderer',
|
|
sha256: RENDERER_V1.sha256,
|
|
download: () => ({offset: 10, chunks: chunksOf(RENDERER_V1.packed.subarray(10))}),
|
|
}),
|
|
/resumed at byte 10, expected 0/u,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ModuleStore rejected module sets', () => {
|
|
async function bootTwiceWithoutReady(userDataPath) {
|
|
for (let boot = 0; boot < 2; boot += 1) {
|
|
const store = await openStore(userDataPath);
|
|
await store.beginBootAttempt();
|
|
await store.recordLaunchAttempt();
|
|
}
|
|
return await openStore(userDataPath);
|
|
}
|
|
|
|
test('a rollback that reverts remembers the module set it rejected', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
|
|
const rebooted = await bootTwiceWithoutReady(userDataPath);
|
|
const attempt = await rebooted.beginBootAttempt();
|
|
|
|
assert.equal(attempt.rolledBack, true);
|
|
assert.deepEqual(readState(userDataPath).rejected, {fluxer_renderer: RENDERER_V2.sha256});
|
|
assert.equal(rebooted.isRejected('fluxer_renderer', RENDERER_V2.sha256), true);
|
|
assert.equal(rebooted.isRejected('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
});
|
|
|
|
test('the rejection holds while the manifest still advertises that build and clears once it moves on', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await install(store, RENDERER_V2);
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256});
|
|
const rebooted = await bootTwiceWithoutReady(userDataPath);
|
|
await rebooted.beginBootAttempt();
|
|
|
|
await rebooted.recordManifestFetch({
|
|
etag: null,
|
|
fetchedAt: MANIFEST_FETCHED_AT,
|
|
manifest: manifestObservation(1, 'a'.repeat(64)),
|
|
advertised: {fluxer_renderer: RENDERER_V2.sha256},
|
|
});
|
|
assert.equal(rebooted.isRejected('fluxer_renderer', RENDERER_V2.sha256), true);
|
|
|
|
await rebooted.recordManifestFetch({
|
|
etag: null,
|
|
fetchedAt: MANIFEST_FETCHED_AT,
|
|
manifest: manifestObservation(2, 'b'.repeat(64)),
|
|
advertised: {fluxer_renderer: 'c'.repeat(64)},
|
|
});
|
|
assert.equal(rebooted.isRejected('fluxer_renderer', RENDERER_V2.sha256), false);
|
|
assert.deepEqual(readState(userDataPath).rejected, {});
|
|
});
|
|
|
|
test('a state file written before rejections existed still opens', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const statePath = path.join(store.root, MODULE_STATE_FILE_NAME);
|
|
const legacy = readState(userDataPath);
|
|
delete legacy.rejected;
|
|
writeFileSync(statePath, JSON.stringify(legacy));
|
|
|
|
const reopened = await openStore(userDataPath);
|
|
|
|
assert.deepEqual(reopened.getState().rejected, {});
|
|
});
|
|
});
|
|
|
|
describe('ModuleStore shell ranges beside older shells', () => {
|
|
test('state written by this shell stays readable by shells that require linux_security_minimum', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
|
|
const written = readState(userDataPath);
|
|
|
|
assert.equal(Object.hasOwn(written, 'linux_security_minimum'), true);
|
|
assert.equal(written.linux_security_minimum, null);
|
|
assert.equal(written.state_version, MODULE_STATE_VERSION);
|
|
});
|
|
|
|
test('state that persisted a security minimum still opens', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const legacy = readState(userDataPath);
|
|
legacy.linux_security_minimum = {version: '2026.900.1', required_modules: ['fluxer_renderer']};
|
|
delete legacy.shell_ranges;
|
|
writeFileSync(path.join(store.root, MODULE_STATE_FILE_NAME), JSON.stringify(legacy));
|
|
|
|
const reopened = await openStore(userDataPath);
|
|
|
|
assert.equal(reopened.recoveredUnreadableState, null);
|
|
assert.deepEqual(reopened.getCommitted(), {fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.deepEqual(reopened.getState().shell_ranges, {});
|
|
await reopened.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.equal(readState(userDataPath).linux_security_minimum, null);
|
|
});
|
|
|
|
test('shell ranges survive a reopen and garbage collection prunes them', async () => {
|
|
const userDataPath = createUserData();
|
|
const earlierRun = await openStore(userDataPath);
|
|
await install(earlierRun, RENDERER_V1);
|
|
await earlierRun.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const orphan = await install(earlierRun, GRAMMARS_V1);
|
|
await earlierRun.recordShellRanges([
|
|
{module: 'fluxer_renderer', sha256: RENDERER_V1.sha256, minimum: SHELL_VERSION, maximum: null},
|
|
{module: 'fluxer_grammars', sha256: GRAMMARS_V1.sha256, minimum: null, maximum: '2026.900.1'},
|
|
{module: 'fluxer_renderer', sha256: 'd'.repeat(64), minimum: '2026.900.1', maximum: null},
|
|
]);
|
|
const store = await openStore(userDataPath);
|
|
|
|
assert.deepEqual(store.getShellRange('fluxer_renderer', RENDERER_V1.sha256), {
|
|
minimum_shell_version: SHELL_VERSION,
|
|
maximum_shell_version: null,
|
|
});
|
|
assert.deepEqual(store.getShellRange('fluxer_grammars', GRAMMARS_V1.sha256), {
|
|
minimum_shell_version: null,
|
|
maximum_shell_version: '2026.900.1',
|
|
});
|
|
|
|
await markLaunched(store);
|
|
await store.collectGarbage();
|
|
|
|
assert.equal(existsSync(orphan.directory), false);
|
|
assert.deepEqual(Object.keys(readState(userDataPath).shell_ranges), [`fluxer_renderer/${RENDERER_V1.sha256}`]);
|
|
assert.equal(store.getShellRange('fluxer_grammars', GRAMMARS_V1.sha256), null);
|
|
});
|
|
|
|
test('a recorded range that no longer parses is dropped instead of discarding the state', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const state = readState(userDataPath);
|
|
state.shell_ranges = {
|
|
[`fluxer_renderer/${'d'.repeat(64)}`]: {minimum_shell_version: 'not a version', maximum_shell_version: null},
|
|
[`fluxer_renderer/${'e'.repeat(64)}`]: {minimum_shell_version: SHELL_VERSION, maximum_shell_version: null},
|
|
};
|
|
writeFileSync(path.join(store.root, MODULE_STATE_FILE_NAME), JSON.stringify(state));
|
|
|
|
const reopened = await openStore(userDataPath);
|
|
|
|
assert.equal(reopened.recoveredUnreadableState, null);
|
|
assert.deepEqual(Object.keys(reopened.getState().shell_ranges), [`fluxer_renderer/${'e'.repeat(64)}`]);
|
|
});
|
|
|
|
test('retireIncompatible drops non-runnable pairs from committed, previous and floor', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await install(store, RENDERER_V2);
|
|
await install(store, GRAMMARS_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
await store.recordManifestFetch({
|
|
etag: null,
|
|
fetchedAt: MANIFEST_FETCHED_AT,
|
|
manifest: manifestObservation(1, 'a'.repeat(64)),
|
|
floor: {fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256},
|
|
});
|
|
|
|
const retired = await store.retireIncompatible(
|
|
(moduleName, digest) => !(moduleName === 'fluxer_renderer' && digest === RENDERER_V2.sha256),
|
|
);
|
|
|
|
assert.deepEqual(retired, ['fluxer_renderer']);
|
|
assert.deepEqual(store.getCommitted(), {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.deepEqual(store.getState().previous, {fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.deepEqual(store.getState().floor, {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.deepEqual(await store.retireIncompatible(() => true), []);
|
|
});
|
|
});
|
|
|
|
describe('ModuleStore on Windows sharing violations', () => {
|
|
test('a rename that an antivirus scanner briefly locks is retried instead of failing the install', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const platform = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
const rename = fsPromises.rename;
|
|
let failures = 0;
|
|
Object.defineProperty(process, 'platform', {...platform, value: 'win32'});
|
|
fsPromises.rename = async (...args) => {
|
|
if (failures < 2) {
|
|
failures += 1;
|
|
throw Object.assign(new Error('operation not permitted'), {code: 'EPERM'});
|
|
}
|
|
return await rename(...args);
|
|
};
|
|
try {
|
|
await store.recordLaunchAttempt();
|
|
} finally {
|
|
fsPromises.rename = rename;
|
|
Object.defineProperty(process, 'platform', platform);
|
|
}
|
|
|
|
assert.equal(failures, 2);
|
|
assert.equal(readState(userDataPath).boot_attempt, 1);
|
|
});
|
|
|
|
test('the same lock on another platform is not retried', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const rename = fsPromises.rename;
|
|
let calls = 0;
|
|
fsPromises.rename = async () => {
|
|
calls += 1;
|
|
throw Object.assign(new Error('operation not permitted'), {code: 'EPERM'});
|
|
};
|
|
try {
|
|
await assert.rejects(store.recordLaunchAttempt(), /failed to publish/u);
|
|
} finally {
|
|
fsPromises.rename = rename;
|
|
}
|
|
|
|
assert.equal(calls, process.platform === 'win32' ? 7 : 1);
|
|
});
|
|
});
|
|
|
|
describe('ModuleStore boot checks without rehashing', () => {
|
|
async function countServedFileOpens(directory, operation) {
|
|
const open = fsPromises.open;
|
|
const opened = [];
|
|
fsPromises.open = async (target, ...rest) => {
|
|
if (
|
|
typeof target === 'string' &&
|
|
target.startsWith(`${directory}${path.sep}`) &&
|
|
path.basename(target) !== 'module.json' &&
|
|
!path.basename(target).startsWith(MODULE_FILE_STAMPS_NAME)
|
|
) {
|
|
opened.push(target);
|
|
}
|
|
return await open(target, ...rest);
|
|
};
|
|
try {
|
|
return {result: await operation(), opened};
|
|
} finally {
|
|
fsPromises.open = open;
|
|
}
|
|
}
|
|
|
|
test('an installed module is trusted from its persisted file stamps without reading a served file', async () => {
|
|
const userDataPath = createUserData();
|
|
const first = await openStore(userDataPath);
|
|
const installed = await install(first, RENDERER_V1);
|
|
await first.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.equal(existsSync(path.join(installed.directory, MODULE_FILE_STAMPS_NAME)), true);
|
|
|
|
const store = await openStore(userDataPath);
|
|
const {result, opened} = await countServedFileOpens(installed.directory, async () => {
|
|
const isInstalled = await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256);
|
|
const index = await store.buildModuleIndex();
|
|
return {isInstalled, index};
|
|
});
|
|
|
|
assert.equal(result.isInstalled, true);
|
|
assert.deepEqual([...result.index.keys()].sort(), ['assets/index-v1.js', 'assets/style-v1.css']);
|
|
assert.deepEqual(opened, []);
|
|
});
|
|
|
|
test('a fresh install launches from its in-memory stamps and persists them only once the tree is durable', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await store.installModule({
|
|
module: RENDERER_V1.module,
|
|
sha256: RENDERER_V1.sha256,
|
|
download: () => streamOf(RENDERER_V1.packed),
|
|
});
|
|
const stampsPath = path.join(installed.directory, MODULE_FILE_STAMPS_NAME);
|
|
assert.equal(existsSync(stampsPath), false);
|
|
|
|
const {result, opened} = await countServedFileOpens(installed.directory, async () => {
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
return await store.buildModuleIndex();
|
|
});
|
|
assert.deepEqual([...result.keys()].sort(), ['assets/index-v1.js', 'assets/style-v1.css']);
|
|
assert.deepEqual(opened, []);
|
|
assert.equal(existsSync(stampsPath), false);
|
|
|
|
const attempt = await store.recordLaunchAttempt();
|
|
assert.equal(await store.markLaunchAttemptSucceeded(attempt), true);
|
|
await store.settleInstalledModules();
|
|
assert.equal(existsSync(stampsPath), true);
|
|
const reopened = await openStore(userDataPath);
|
|
const later = await countServedFileOpens(installed.directory, () =>
|
|
reopened.isInstalled('fluxer_renderer', RENDERER_V1.sha256),
|
|
);
|
|
assert.equal(later.result, true);
|
|
assert.deepEqual(later.opened, []);
|
|
});
|
|
|
|
test('a served file rewritten with the same length and its old mtime is still caught', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const served = path.join(installed.directory, 'assets', 'index-v1.js');
|
|
const before = await fsPromises.stat(served);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
writeFileSync(served, 'RENDERER ONE');
|
|
utimesSync(served, before.atime, before.mtime);
|
|
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
await assert.rejects(store.buildModuleIndex(), ModuleStoreInstallationMissingError);
|
|
});
|
|
|
|
test('a deleted served file is caught without hashing the rest of the module', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
await rm(path.join(installed.directory, 'assets', 'style-v1.css'));
|
|
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
});
|
|
|
|
test('an installation without stamps is hashed once and then trusted from the stamps it wrote', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const stampsPath = path.join(installed.directory, MODULE_FILE_STAMPS_NAME);
|
|
await rm(stampsPath);
|
|
|
|
const firstPass = await countServedFileOpens(installed.directory, () =>
|
|
store.isInstalled('fluxer_renderer', RENDERER_V1.sha256),
|
|
);
|
|
assert.equal(firstPass.result, true);
|
|
assert.equal(firstPass.opened.length, 2);
|
|
assert.equal(existsSync(stampsPath), true);
|
|
|
|
const secondPass = await countServedFileOpens(installed.directory, () =>
|
|
store.isInstalled('fluxer_renderer', RENDERER_V1.sha256),
|
|
);
|
|
assert.equal(secondPass.result, true);
|
|
assert.deepEqual(secondPass.opened, []);
|
|
});
|
|
|
|
test('unreadable or foreign stamps fall back to hashing instead of trusting the tree', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const installed = await install(store, RENDERER_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const stampsPath = path.join(installed.directory, MODULE_FILE_STAMPS_NAME);
|
|
const stamps = JSON.parse(readFileSync(stampsPath, 'utf8'));
|
|
const served = path.join(installed.directory, 'assets', 'index-v1.js');
|
|
writeFileSync(served, 'RENDERER ONE');
|
|
const tampered = await fsPromises.lstat(served);
|
|
const forged = {
|
|
...stamps,
|
|
manifest_sha256: 'f'.repeat(64),
|
|
files: {
|
|
...stamps.files,
|
|
'assets/index-v1.js': [tampered.size, tampered.mtimeMs, tampered.ctimeMs, tampered.ino],
|
|
},
|
|
};
|
|
writeFileSync(stampsPath, JSON.stringify(forged));
|
|
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
|
|
writeFileSync(stampsPath, 'not json');
|
|
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
|
});
|
|
});
|
|
|
|
describe('ModuleStore beside a renderer bundled in the shell', () => {
|
|
test('an unreadable state file is set aside and the store starts clean', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const statePath = path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME);
|
|
for (const contents of ['{not json', JSON.stringify({state_version: 1})]) {
|
|
writeFileSync(statePath, contents);
|
|
|
|
const recovered = await openStore(userDataPath);
|
|
|
|
assert.equal(recovered.recoveredUnreadableState, `${statePath}.unreadable`);
|
|
assert.equal(readFileSync(`${statePath}.unreadable`, 'utf8'), contents);
|
|
assert.deepEqual(recovered.getCommitted(), {});
|
|
assert.equal(readState(userDataPath).state_version, MODULE_STATE_VERSION);
|
|
}
|
|
assert.equal(store.recoveredUnreadableState, null);
|
|
});
|
|
|
|
test('retiring a module drops it from the committed and previous sets so it can be collected', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
const first = await install(store, RENDERER_V1);
|
|
const second = await install(store, RENDERER_V2);
|
|
await install(store, GRAMMARS_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
await store.commit({fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
|
|
assert.equal(await store.retireModule('fluxer_renderer'), true);
|
|
assert.equal(await store.retireModule('fluxer_renderer'), false);
|
|
|
|
assert.deepEqual(readState(userDataPath).committed, {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.deepEqual(readState(userDataPath).previous, {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
await markLaunched(store);
|
|
await store.collectGarbage();
|
|
assert.equal(existsSync(first.directory), false);
|
|
assert.equal(existsSync(second.directory), false);
|
|
assert.equal(await store.isInstalled('fluxer_grammars', GRAMMARS_V1.sha256), true);
|
|
});
|
|
|
|
test('two failed boots of a first renderer module fall back to the bundle instead of looping', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await install(store, GRAMMARS_V1);
|
|
await store.commit({fluxer_grammars: GRAMMARS_V1.sha256});
|
|
await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256});
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
|
const state = readState(userDataPath);
|
|
writeFileSync(
|
|
path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME),
|
|
JSON.stringify({...state, previous: {}, boot_attempt: 2}),
|
|
);
|
|
const reopened = await openStore(userDataPath);
|
|
|
|
const withoutBundle = await reopened.beginBootAttempt();
|
|
assert.equal(withoutBundle.rolledBack, false);
|
|
writeFileSync(
|
|
path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME),
|
|
JSON.stringify({...readState(userDataPath), boot_attempt: 2}),
|
|
);
|
|
const bundled = await openStore(userDataPath);
|
|
|
|
const attempt = await bundled.beginBootAttempt({bundled: ['fluxer_renderer']});
|
|
|
|
assert.equal(attempt.rolledBack, true);
|
|
assert.deepEqual(attempt.committed, {fluxer_grammars: GRAMMARS_V1.sha256});
|
|
assert.equal(bundled.isRejected('fluxer_renderer', RENDERER_V1.sha256), true);
|
|
assert.equal(readState(userDataPath).boot_attempt, 0);
|
|
});
|
|
|
|
test('a renderer update that never confirms is reverted and rejected', async () => {
|
|
const userDataPath = createUserData();
|
|
const store = await openStore(userDataPath);
|
|
await install(store, RENDERER_V1);
|
|
await install(store, RENDERER_V2);
|
|
await install(store, GRAMMARS_V1);
|
|
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
|
const attempt = await store.activateMergedForRendererReload({
|
|
fluxer_renderer: RENDERER_V2.sha256,
|
|
fluxer_grammars: GRAMMARS_V1.sha256,
|
|
});
|
|
|
|
assert.deepEqual(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), {
|
|
fluxer_renderer: RENDERER_V1.sha256,
|
|
fluxer_grammars: GRAMMARS_V1.sha256,
|
|
});
|
|
assert.equal(store.isRejected('fluxer_renderer', RENDERER_V2.sha256), true);
|
|
assert.equal(store.isRejected('fluxer_grammars', GRAMMARS_V1.sha256), false);
|
|
assert.equal(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), null);
|
|
|
|
const first = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V2.sha256});
|
|
await store.revertLaunchAttempt(first, ['fluxer_renderer']);
|
|
await store.retireModule('fluxer_renderer');
|
|
const onBundle = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256});
|
|
assert.deepEqual(await store.revertLaunchAttempt(onBundle, ['fluxer_renderer']), {
|
|
fluxer_grammars: GRAMMARS_V1.sha256,
|
|
});
|
|
});
|
|
});
|