forked from EduCraft/curriculum-project-hub
fix: enforce active organization boundary
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface WorkspaceFileSnapshot {
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
export interface WorkspaceFileWriteResult {
|
||||
readonly path: string;
|
||||
readonly device: number;
|
||||
readonly inode: number;
|
||||
}
|
||||
|
||||
interface OpenDirectory {
|
||||
readonly handle: FileHandle;
|
||||
readonly displayPath: string;
|
||||
@@ -72,6 +78,21 @@ export async function writeNewWorkspaceFileNoFollow(
|
||||
requestedPath: string,
|
||||
source: Readable,
|
||||
): Promise<string> {
|
||||
return (await writeNewWorkspaceFileNoFollowTracked(
|
||||
workspaceRoot,
|
||||
workspaceDir,
|
||||
requestedPath,
|
||||
source,
|
||||
)).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,
|
||||
): Promise<WorkspaceFileWriteResult> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
const name = components.at(-1)!;
|
||||
@@ -81,7 +102,7 @@ export async function writeNewWorkspaceFileNoFollow(
|
||||
let file: FileHandle | undefined;
|
||||
let device: number | undefined;
|
||||
let inode: number | undefined;
|
||||
let result: string | undefined;
|
||||
let result: WorkspaceFileWriteResult | undefined;
|
||||
let failure: unknown;
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try {
|
||||
@@ -104,7 +125,11 @@ export async function writeNewWorkspaceFileNoFollow(
|
||||
}
|
||||
await file.sync();
|
||||
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
|
||||
result = join(workspace, ...components);
|
||||
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) {
|
||||
@@ -124,6 +149,103 @@ export async function writeNewWorkspaceFileNoFollow(
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user