234 lines
10 KiB
TypeScript
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);
|
|
}
|
|
}
|