Files
makelore/electron/coding-runtime/work-entry-store.ts
T

234 lines
10 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { constants, type Stats } from 'node:fs';
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
import path from 'node:path';
import type { WorkEntry, WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts';
const ENTRY_FILENAME = 'work-entry.json';
const MAX_ENTRY_BYTES = 16 * 1024;
const MAX_URL_LENGTH = 2_048;
const MAX_PATH_LENGTH = 1_024;
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
type ProjectRoot = { root: string; rootStat: Stats };
type Locations = ProjectRoot & { directory: string; file: string; directoryStat: Stats };
function invalid(): never {
throw new Error('Invalid project work entry');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
function hasOnlyKeys(value: Record<string, unknown>, keys: string[]): boolean {
return Object.keys(value).every((key) => keys.includes(key));
}
function samePath(left: string, right: string): boolean {
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
}
function sameNode(left: Stats, right: Stats): boolean {
return left.dev === right.dev && left.ino === right.ino;
}
function containsControl(value: string, includeSpace = false): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code < (includeSpace ? 33 : 32) || code === 127) return true;
}
return false;
}
async function projectRoot(project: WorkEntryProject): Promise<ProjectRoot> {
if (typeof project.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(project.id)
|| typeof project.path !== 'string' || !path.isAbsolute(project.path)) invalid();
const root = await realpath(project.path);
const rootStat = await lstat(root);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) invalid();
return { root, rootStat };
}
async function assertRoot(target: ProjectRoot): Promise<void> {
const stat = await lstat(target.root);
if (!stat.isDirectory() || stat.isSymbolicLink() || !sameNode(stat, target.rootStat)
|| !samePath(await realpath(target.root), target.root)) invalid();
}
async function locations(project: WorkEntryProject, createMetadata = false, verifiedRoot?: ProjectRoot): Promise<Locations> {
const { root, rootStat } = verifiedRoot ?? await projectRoot(project);
const directory = path.join(root, '.makelore');
const directoryStat = await lstat(directory).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT' || !createMetadata) throw error;
await assertRoot({ root, rootStat });
// Imported folders may not have metadata yet. Create only this direct child;
// a concurrent creator must still pass the same directory/link checks below.
await mkdir(directory, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => {
if (mkdirError.code !== 'EEXIST') throw mkdirError;
});
return await lstat(directory);
});
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()
|| !samePath(await realpath(directory), directory)) invalid();
await assertRoot({ root, rootStat });
return { root, directory, file: path.join(directory, ENTRY_FILENAME), rootStat, directoryStat };
}
async function assertLocations(target: Locations): Promise<void> {
await assertRoot(target);
const directoryStat = await lstat(target.directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || !sameNode(directoryStat, target.directoryStat)
|| !samePath(await realpath(target.directory), target.directory)) invalid();
}
function relativePath(value: unknown): string {
if (typeof value !== 'string' || !value || value.length > MAX_PATH_LENGTH
|| containsControl(value) || /[\\:]/u.test(value) || value.trim() !== value
|| path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) invalid();
const pieces = value.split('/');
if (pieces.length > 32 || pieces.includes('..')) invalid();
return path.posix.normalize(value);
}
async function relativeTarget(root: string, value: unknown, kind: 'directory' | 'file'): Promise<string> {
const relative = relativePath(value);
let current = root;
for (const piece of relative.split('/').filter((piece) => piece !== '.')) {
current = path.join(current, piece);
if ((await lstat(current)).isSymbolicLink()) invalid();
}
const stat = await lstat(current);
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile())
|| !samePath(await realpath(current), current)) invalid();
return relative;
}
function localUrl(value: unknown): string {
if (typeof value !== 'string' || value.length > MAX_URL_LENGTH
|| containsControl(value, true)) invalid();
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)
|| !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|| url.username || url.password) invalid();
return value;
}
/** Only structured Vite options are persisted; the launcher supplies the loopback host. */
function viteArgs(value: unknown): string[] {
if (!Array.isArray(value) || value.length > 16) invalid();
const result: string[] = [];
const seen = new Set<string>();
for (let index = 0; index < value.length; index += 1) {
const flag: unknown = value[index];
if (typeof flag !== 'string' || seen.has(flag)) invalid();
seen.add(flag);
if (flag === '--strictPort' || flag === '--force') {
result.push(flag);
continue;
}
const parameter: unknown = value[++index];
if (typeof parameter !== 'string') invalid();
if (flag === '--port') {
if (!/^[1-9][0-9]{0,4}$/u.test(parameter) || Number(parameter) > 65_535) invalid();
} else if (flag === '--mode') {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(parameter)) invalid();
} else if (flag === '--base') {
if (parameter.length > MAX_PATH_LENGTH || !/^(?:\/(?!\/)|\.\/)/u.test(parameter)
|| containsControl(parameter, true) || /[\\?#:]/u.test(parameter)
|| parameter.split('/').includes('..')) invalid();
} else invalid();
result.push(flag, parameter);
}
return result;
}
async function launchSpec(root: string, value: unknown): Promise<WorkLaunchSpec> {
if (!isRecord(value)) invalid();
if (value.kind === 'vite' && hasOnlyKeys(value, ['kind', 'directory', 'args'])) {
return { kind: 'vite', directory: await relativeTarget(root, value.directory, 'directory'), args: viteArgs(value.args) };
}
if (value.kind === 'static' && hasOnlyKeys(value, ['kind', 'directory', 'entry'])) {
const directory = await relativeTarget(root, value.directory, 'directory');
const entry = await relativeTarget(path.join(root, directory), value.entry, 'file');
return { kind: 'static', directory, entry };
}
return invalid();
}
async function validatedEntry(project: WorkEntryProject, root: string, value: unknown, saving = false): Promise<WorkEntry> {
if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'projectId', 'projectPath', 'url', 'launch', 'documentHash'])
|| value.version !== 1 || value.projectId !== project.id || typeof value.projectPath !== 'string'
|| !path.isAbsolute(value.projectPath)) invalid();
const boundPath = saving ? await realpath(value.projectPath) : value.projectPath;
if (!samePath(boundPath, root)) invalid();
if (value.documentHash !== undefined
&& (typeof value.documentHash !== 'string' || !/^[a-f0-9]{64}$/u.test(value.documentHash))) invalid();
return {
version: 1, projectId: project.id, projectPath: root, url: localUrl(value.url),
...(value.launch === undefined ? {} : { launch: await launchSpec(root, value.launch) }),
...(value.documentHash === undefined ? {} : { documentHash: value.documentHash }),
};
}
/** Reads bounded project-owned metadata without following links or starting any work. */
export async function loadWorkEntry(project: WorkEntryProject): Promise<WorkEntry | null> {
try {
const target = await locations(project);
const fileStat = await lstat(target.file);
if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > MAX_ENTRY_BYTES) return null;
const handle = await open(target.file, constants.O_RDONLY | NO_FOLLOW);
try {
const openedStat = await handle.stat();
if (!openedStat.isFile() || openedStat.size > MAX_ENTRY_BYTES || !sameNode(fileStat, openedStat)) return null;
const bytes = Buffer.alloc(MAX_ENTRY_BYTES + 1);
let size = 0;
while (size < bytes.length) {
const chunk = await handle.read(bytes, size, bytes.length - size, size);
if (!chunk.bytesRead) break;
size += chunk.bytesRead;
}
if (size > MAX_ENTRY_BYTES) return null;
await assertLocations(target);
const currentStat = await lstat(target.file);
if (currentStat.isSymbolicLink() || !sameNode(openedStat, currentStat)) return null;
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size));
return await validatedEntry(project, target.root, JSON.parse(source));
} finally {
await handle.close();
}
} catch {
// Missing, copied, changed or corrupt metadata is never launch authority.
return null;
}
}
/** Publishes one complete entry in project metadata, using an exclusive temp file. */
export async function saveWorkEntry(project: WorkEntryProject, entry: WorkEntry): Promise<void> {
const root = await projectRoot(project);
const value = await validatedEntry(project, root.root, entry, true);
const source = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(source, 'utf8') > MAX_ENTRY_BYTES) invalid();
const target = await locations(project, true, root);
await assertLocations(target);
const temporary = path.join(target.directory, `.${ENTRY_FILENAME}.${randomUUID()}.tmp`);
try {
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 0o600);
try {
await handle.writeFile(source, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await assertLocations(target);
const existing = await lstat(target.file).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return null;
});
if (existing && (!existing.isFile() || existing.isSymbolicLink())) invalid();
await rename(temporary, target.file);
} finally {
await assertLocations(target).then(() => rm(temporary, { force: true })).catch(() => undefined);
}
}