forked from EduCraft/curriculum-project-hub
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { link, lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
|
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import type { Readable } from "node:stream";
|
|
|
|
export class WorkspaceFileBoundaryError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly requestedPath: string,
|
|
readonly reason: "not_found" | "boundary" | "io" | "limit" = "boundary",
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(message, options);
|
|
this.name = "WorkspaceFileBoundaryError";
|
|
}
|
|
}
|
|
|
|
export interface WorkspaceFileSnapshot {
|
|
readonly path: string;
|
|
readonly name: string;
|
|
readonly data: Buffer;
|
|
}
|
|
|
|
export interface WorkspaceFileWriteResult {
|
|
readonly path: string;
|
|
readonly device: number;
|
|
readonly inode: number;
|
|
}
|
|
|
|
interface OpenDirectory {
|
|
readonly handle: FileHandle;
|
|
readonly displayPath: string;
|
|
}
|
|
|
|
/** Read a stable snapshot without following any symlink below the workspace. */
|
|
export async function readWorkspaceFileNoFollow(
|
|
workspaceRoot: string,
|
|
workspaceDir: string,
|
|
requestedPath: string,
|
|
maxBytes?: number,
|
|
): Promise<WorkspaceFileSnapshot> {
|
|
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
|
const components = fileComponents(workspace, requestedPath);
|
|
const name = components.at(-1)!;
|
|
const directories = components.slice(0, -1);
|
|
const parent = await openDirectoryTree(workspace, directories, false, requestedPath);
|
|
const openedPath = childPath(parent, name);
|
|
let file: FileHandle | undefined;
|
|
let result: WorkspaceFileSnapshot | undefined;
|
|
let failure: unknown;
|
|
try {
|
|
file = await open(openedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
const metadata = await file.stat();
|
|
if (!metadata.isFile()) {
|
|
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
|
|
}
|
|
if (maxBytes !== undefined && metadata.size > maxBytes) {
|
|
throw new WorkspaceFileBoundaryError(
|
|
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
|
|
requestedPath,
|
|
"limit",
|
|
);
|
|
}
|
|
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
|
const data = await readFileSnapshot(file, maxBytes, requestedPath);
|
|
result = { path: join(workspace, ...components), name, data };
|
|
} catch (error) {
|
|
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
|
|
}
|
|
await finishWithCleanup(
|
|
failure,
|
|
[file, parent.handle],
|
|
`workspace file read cleanup failed: ${requestedPath}`,
|
|
);
|
|
return result!;
|
|
}
|
|
|
|
async function readFileSnapshot(
|
|
file: FileHandle,
|
|
maxBytes: number | undefined,
|
|
requestedPath: string,
|
|
): Promise<Buffer> {
|
|
if (maxBytes === undefined) return file.readFile();
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
let position = 0;
|
|
while (true) {
|
|
const remainingWithSentinel = maxBytes - total + 1;
|
|
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remainingWithSentinel));
|
|
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
|
|
if (bytesRead === 0) return Buffer.concat(chunks, total);
|
|
total += bytesRead;
|
|
if (total > maxBytes) {
|
|
throw new WorkspaceFileBoundaryError(
|
|
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
|
|
requestedPath,
|
|
"limit",
|
|
);
|
|
}
|
|
chunks.push(chunk.subarray(0, bytesRead));
|
|
position += bytesRead;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create one inbound file exclusively below the workspace and stream into its
|
|
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
|
|
* concurrent directory rename/symlink swap cannot redirect the write.
|
|
*/
|
|
export async function writeNewWorkspaceFileNoFollow(
|
|
workspaceRoot: string,
|
|
workspaceDir: string,
|
|
requestedPath: string,
|
|
source: Readable,
|
|
maxBytes?: number,
|
|
): Promise<string> {
|
|
return (await writeNewWorkspaceFileNoFollowTracked(
|
|
workspaceRoot,
|
|
workspaceDir,
|
|
requestedPath,
|
|
source,
|
|
maxBytes,
|
|
)).path;
|
|
}
|
|
|
|
/** Create one inbound file and return its identity for race-safe compensation. */
|
|
export async function writeNewWorkspaceFileNoFollowTracked(
|
|
workspaceRoot: string,
|
|
workspaceDir: string,
|
|
requestedPath: string,
|
|
source: Readable,
|
|
maxBytes?: number,
|
|
): Promise<WorkspaceFileWriteResult> {
|
|
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
|
const components = fileComponents(workspace, requestedPath);
|
|
const name = components.at(-1)!;
|
|
const directories = components.slice(0, -1);
|
|
const parent = await openDirectoryTree(workspace, directories, true, requestedPath);
|
|
const openedPath = childPath(parent, name);
|
|
let file: FileHandle | undefined;
|
|
let device: number | undefined;
|
|
let inode: number | undefined;
|
|
let result: WorkspaceFileWriteResult | undefined;
|
|
let failure: unknown;
|
|
const cleanupFailures: unknown[] = [];
|
|
try {
|
|
file = await open(
|
|
openedPath,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
const metadata = await file.stat();
|
|
device = metadata.dev;
|
|
inode = metadata.ino;
|
|
let bytesWritten = 0;
|
|
for await (const chunk of source) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
bytesWritten += buffer.length;
|
|
if (maxBytes !== undefined && bytesWritten > maxBytes) {
|
|
throw new WorkspaceFileBoundaryError(
|
|
`inbound file exceeds ${maxBytes} bytes: ${requestedPath}`,
|
|
requestedPath,
|
|
"limit",
|
|
);
|
|
}
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
|
|
if (bytesWritten === 0) throw new Error("inbound workspace write made no progress");
|
|
offset += bytesWritten;
|
|
}
|
|
}
|
|
await file.sync();
|
|
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
|
result = {
|
|
path: join(workspace, ...components),
|
|
device: metadata.dev,
|
|
inode: metadata.ino,
|
|
};
|
|
} catch (error) {
|
|
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
|
|
if (device !== undefined && inode !== undefined) {
|
|
try {
|
|
await unlinkIfSameFile(parent, name, device, inode);
|
|
} catch (cleanupError) {
|
|
cleanupFailures.push(cleanupError);
|
|
}
|
|
}
|
|
}
|
|
await finishWithCleanup(
|
|
failure,
|
|
[file, parent.handle],
|
|
`inbound workspace file cleanup failed: ${requestedPath}`,
|
|
cleanupFailures,
|
|
);
|
|
return result!;
|
|
}
|
|
|
|
/** Remove only the exact file created by a tracked no-follow write. */
|
|
export async function removeWorkspaceFileIfUnchangedNoFollow(
|
|
workspaceRoot: string,
|
|
workspaceDir: string,
|
|
requestedPath: string,
|
|
expectedDevice: number,
|
|
expectedInode: number,
|
|
): Promise<void> {
|
|
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
|
const components = fileComponents(workspace, requestedPath);
|
|
const name = components.at(-1)!;
|
|
const parent = await openDirectoryTree(workspace, components.slice(0, -1), false, requestedPath);
|
|
let failure: unknown;
|
|
try {
|
|
await unlinkIfSameFile(parent, name, expectedDevice, expectedInode);
|
|
} catch (error) {
|
|
failure = boundaryError(error, requestedPath, "cannot compensate inbound workspace file safely");
|
|
}
|
|
await finishWithCleanup(
|
|
failure,
|
|
[parent.handle],
|
|
`inbound workspace compensation cleanup failed: ${requestedPath}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Publish a protected same-filesystem staging file with one exclusive hard-link
|
|
* operation. The target path is traversed without following workspace symlinks.
|
|
*/
|
|
export async function linkWorkspaceFileNoFollow(
|
|
workspaceRoot: string,
|
|
workspaceDir: string,
|
|
requestedPath: string,
|
|
stagedPath: string,
|
|
): Promise<WorkspaceFileWriteResult> {
|
|
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
|
const components = fileComponents(workspace, requestedPath);
|
|
const name = components.at(-1)!;
|
|
const parent = await openDirectoryTree(workspace, components.slice(0, -1), true, requestedPath);
|
|
let source: FileHandle | undefined;
|
|
let result: WorkspaceFileWriteResult | undefined;
|
|
let failure: unknown;
|
|
const cleanupFailures: unknown[] = [];
|
|
try {
|
|
source = await open(stagedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
const metadata = await source.stat();
|
|
if (!metadata.isFile()) {
|
|
throw new WorkspaceFileBoundaryError(`staged resource is not a regular file: ${stagedPath}`, stagedPath);
|
|
}
|
|
const targetPath = childPath(parent, name);
|
|
await link(stagedPath, targetPath);
|
|
try {
|
|
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
|
} catch (error) {
|
|
await unlinkIfSameFile(parent, name, metadata.dev, metadata.ino).catch((cleanupError) => {
|
|
cleanupFailures.push(cleanupError);
|
|
});
|
|
throw error;
|
|
}
|
|
result = {
|
|
path: join(workspace, ...components),
|
|
device: metadata.dev,
|
|
inode: metadata.ino,
|
|
};
|
|
} catch (error) {
|
|
failure = boundaryError(error, requestedPath, "cannot publish staged workspace file safely");
|
|
}
|
|
try {
|
|
await finishWithCleanup(
|
|
failure,
|
|
[source, parent.handle],
|
|
`staged workspace publication cleanup failed: ${requestedPath}`,
|
|
cleanupFailures,
|
|
);
|
|
} catch (error) {
|
|
if (result !== undefined) {
|
|
try {
|
|
await removeWorkspaceFileIfUnchangedNoFollow(
|
|
workspaceRoot,
|
|
workspaceDir,
|
|
requestedPath,
|
|
result.device,
|
|
result.inode,
|
|
);
|
|
} catch (compensationError) {
|
|
throw new AggregateError(
|
|
[error, compensationError],
|
|
`staged workspace publication and compensation both failed: ${requestedPath}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
return result!;
|
|
}
|
|
|
|
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
|
|
if (process.platform !== "linux") {
|
|
throw new WorkspaceFileBoundaryError(
|
|
"race-safe descriptor-relative workspace access requires Linux",
|
|
workspaceDir,
|
|
);
|
|
}
|
|
if (!isAbsolute(workspaceRoot) || !isAbsolute(workspaceDir)) {
|
|
throw new WorkspaceFileBoundaryError("workspace root and project path must be absolute", workspaceDir);
|
|
}
|
|
const configuredRoot = resolve(workspaceRoot);
|
|
const configuredWorkspace = resolve(workspaceDir);
|
|
const rel = relative(configuredRoot, configuredWorkspace);
|
|
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
throw new WorkspaceFileBoundaryError("project workspace escapes configured workspace root", workspaceDir);
|
|
}
|
|
const rootMetadata = await lstat(configuredRoot);
|
|
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
|
throw new WorkspaceFileBoundaryError("configured workspace root is not a real directory", workspaceRoot);
|
|
}
|
|
const root = await realpath(configuredRoot);
|
|
const components = rel.split(sep);
|
|
const openedWorkspace = await openDirectoryTree(root, components, false, workspaceDir);
|
|
let result: string | undefined;
|
|
let failure: unknown;
|
|
try {
|
|
const openedTarget = await realpath(`/proc/self/fd/${openedWorkspace.handle.fd}`);
|
|
const expected = join(root, ...components);
|
|
if (openedTarget !== expected) {
|
|
throw new WorkspaceFileBoundaryError("project workspace contains a symlink", workspaceDir);
|
|
}
|
|
result = expected;
|
|
} catch (error) {
|
|
failure = boundaryError(error, workspaceDir, "project workspace validation failed");
|
|
}
|
|
await finishWithCleanup(
|
|
failure,
|
|
[openedWorkspace.handle],
|
|
`project workspace validation cleanup failed: ${workspaceDir}`,
|
|
);
|
|
return result!;
|
|
}
|
|
|
|
function fileComponents(root: string, requestedPath: string): string[] {
|
|
const token = requestedPath.trim();
|
|
if (token === "" || token.includes("\0")) {
|
|
throw new WorkspaceFileBoundaryError("workspace file path is empty or invalid", requestedPath);
|
|
}
|
|
const absolute = isAbsolute(token) ? resolve(token) : resolve(root, token);
|
|
const rel = relative(root, absolute);
|
|
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
throw new WorkspaceFileBoundaryError(`workspace file path escapes current project: ${requestedPath}`, requestedPath);
|
|
}
|
|
const components = rel.split(/[\\/]/);
|
|
if (components.some((component) => component === "" || component === "." || component === "..")) {
|
|
throw new WorkspaceFileBoundaryError(`workspace file path is not normalized: ${requestedPath}`, requestedPath);
|
|
}
|
|
return components;
|
|
}
|
|
|
|
async function openDirectoryTree(
|
|
root: string,
|
|
components: readonly string[],
|
|
create: boolean,
|
|
requestedPath: string,
|
|
): Promise<OpenDirectory> {
|
|
let current: OpenDirectory = {
|
|
handle: await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW),
|
|
displayPath: root,
|
|
};
|
|
try {
|
|
for (const component of components) {
|
|
const path = childPath(current, component);
|
|
let next: FileHandle;
|
|
try {
|
|
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
} catch (error) {
|
|
if (!create || !isErrno(error, "ENOENT")) throw error;
|
|
try {
|
|
await mkdir(path, { mode: 0o700 });
|
|
} catch (mkdirError) {
|
|
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
|
}
|
|
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
}
|
|
const previous = current;
|
|
current = { handle: next, displayPath: join(previous.displayPath, component) };
|
|
await previous.handle.close();
|
|
}
|
|
return current;
|
|
} catch (error) {
|
|
try {
|
|
await current.handle.close();
|
|
} catch (closeError) {
|
|
throw new AggregateError(
|
|
[error, closeError],
|
|
`workspace path validation and directory close both failed: ${requestedPath}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
throw boundaryError(error, requestedPath, "workspace path contains a symlink or non-directory component");
|
|
}
|
|
}
|
|
|
|
function childPath(parent: OpenDirectory, name: string): string {
|
|
return `/proc/self/fd/${parent.handle.fd}/${name}`;
|
|
}
|
|
|
|
async function assertNameStillReferences(
|
|
parent: OpenDirectory,
|
|
name: string,
|
|
device: number,
|
|
inode: number,
|
|
requestedPath: string,
|
|
): Promise<void> {
|
|
const metadata = await lstat(childPath(parent, name));
|
|
if (metadata.isSymbolicLink() || metadata.dev !== device || metadata.ino !== inode) {
|
|
throw new WorkspaceFileBoundaryError(
|
|
`workspace file changed during no-follow access: ${requestedPath}`,
|
|
requestedPath,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function unlinkIfSameFile(parent: OpenDirectory, name: string, device: number, inode: number): Promise<void> {
|
|
const path = childPath(parent, name);
|
|
try {
|
|
const metadata = await lstat(path);
|
|
if (!metadata.isSymbolicLink() && metadata.dev === device && metadata.ino === inode) {
|
|
await unlink(path);
|
|
}
|
|
} catch (error) {
|
|
if (!isErrno(error, "ENOENT")) throw error;
|
|
}
|
|
}
|
|
|
|
function boundaryError(error: unknown, requestedPath: string, prefix: string): WorkspaceFileBoundaryError {
|
|
if (error instanceof WorkspaceFileBoundaryError) return error;
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
const reason = isErrno(error, "ENOENT")
|
|
? "not_found"
|
|
: isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")
|
|
? "boundary"
|
|
: "io";
|
|
return new WorkspaceFileBoundaryError(
|
|
`${prefix}: ${requestedPath} (${detail})`,
|
|
requestedPath,
|
|
reason,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
async function finishWithCleanup(
|
|
primaryFailure: unknown,
|
|
handles: ReadonlyArray<FileHandle | undefined>,
|
|
message: string,
|
|
additionalFailures: readonly unknown[] = [],
|
|
): Promise<void> {
|
|
const settled = await Promise.allSettled(
|
|
handles.filter((handle): handle is FileHandle => handle !== undefined).map((handle) => handle.close()),
|
|
);
|
|
const cleanupFailures = [
|
|
...additionalFailures,
|
|
...settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
|
];
|
|
if (primaryFailure !== undefined && cleanupFailures.length === 0) throw primaryFailure;
|
|
if (primaryFailure !== undefined || cleanupFailures.length > 0) {
|
|
const failures = primaryFailure === undefined ? cleanupFailures : [primaryFailure, ...cleanupFailures];
|
|
throw new AggregateError(failures, message, { cause: primaryFailure ?? cleanupFailures[0] });
|
|
}
|
|
}
|
|
|
|
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
return error instanceof Error && "code" in error && error.code === code;
|
|
}
|