148 lines
5.2 KiB
TypeScript
148 lines
5.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { lstat, open, realpath, rename, rm } from "node:fs/promises";
|
|
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
import { HarnessError } from "../errors/harness-error.js";
|
|
|
|
const protectedSegments = new Set([
|
|
".env",
|
|
".git",
|
|
".ssh",
|
|
"id_rsa",
|
|
"id_ed25519",
|
|
]);
|
|
|
|
export type ReportDraftInput = {
|
|
title?: string;
|
|
body: string;
|
|
};
|
|
|
|
export class SafeWorkspace {
|
|
private constructor(public readonly root: string) {}
|
|
|
|
public static async create(root: string): Promise<SafeWorkspace> {
|
|
const canonicalRoot = await realpath(resolve(root));
|
|
if (canonicalRoot === parse(canonicalRoot).root) {
|
|
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Filesystem root cannot be used as a workspace");
|
|
}
|
|
const metadata = await lstat(canonicalRoot);
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Workspace root must be a regular directory");
|
|
}
|
|
return new SafeWorkspace(canonicalRoot);
|
|
}
|
|
|
|
public async resolveReadable(inputPath: string): Promise<string> {
|
|
const canonical = await this.resolveExisting(inputPath);
|
|
const metadata = await lstat(canonical);
|
|
if (!metadata.isFile()) {
|
|
throw new HarnessError("NOT_A_REGULAR_FILE", "Only regular files may be read");
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
public async resolveExisting(inputPath: string): Promise<string> {
|
|
const candidate = isAbsolute(inputPath) ? resolve(inputPath) : resolve(this.root, inputPath);
|
|
this.#assertContained(candidate);
|
|
this.#assertUnprotected(candidate);
|
|
await this.#assertNoSymlinkSegments(candidate);
|
|
const canonical = await realpath(candidate);
|
|
this.#assertContained(canonical);
|
|
return canonical;
|
|
}
|
|
|
|
public async writeReportDraft(input: ReportDraftInput): Promise<{ relativePath: "report.md"; byteSize: number }> {
|
|
if (input.body.trim().length === 0) {
|
|
throw new HarnessError("REPORT_BODY_REQUIRED", "Report body cannot be empty");
|
|
}
|
|
|
|
const target = join(this.root, "report.md");
|
|
const temporary = join(this.root, `.report-${randomUUID()}.tmp`);
|
|
this.#assertContained(target);
|
|
await this.#assertNoSymlinkSegments(this.root);
|
|
|
|
const title = input.title?.trim();
|
|
const content = title === undefined || title.length === 0
|
|
? `${input.body.trim()}\n`
|
|
: `# ${title}\n\n${input.body.trim()}\n`;
|
|
const bytes = Buffer.from(content, "utf8");
|
|
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
|
|
try {
|
|
handle = await open(temporary, "wx", 0o600);
|
|
await handle.writeFile(bytes);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
|
|
try {
|
|
const existing = await lstat(target);
|
|
if (existing.isSymbolicLink()) {
|
|
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Report target cannot be a symbolic link");
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof HarnessError) throw error;
|
|
if (!isMissingFileError(error)) throw error;
|
|
}
|
|
|
|
await rename(temporary, target);
|
|
return { relativePath: "report.md", byteSize: bytes.byteLength };
|
|
} finally {
|
|
await handle?.close().catch(() => undefined);
|
|
await rm(temporary, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
public async remove(): Promise<void> {
|
|
const metadata = await lstat(this.root).catch((error: unknown) => {
|
|
if (isMissingFileError(error)) return undefined;
|
|
throw error;
|
|
});
|
|
if (metadata === undefined) return;
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
throw new HarnessError("UNSAFE_WORKSPACE_ROOT", "Refusing to remove a non-directory workspace root");
|
|
}
|
|
await rm(this.root, { recursive: true, force: false });
|
|
}
|
|
|
|
#assertContained(candidate: string): void {
|
|
const pathFromRoot = relative(this.root, candidate);
|
|
if (pathFromRoot === "") return;
|
|
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
|
|
throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path is outside the Session workspace");
|
|
}
|
|
}
|
|
|
|
#assertUnprotected(candidate: string): void {
|
|
const pathFromRoot = relative(this.root, candidate);
|
|
const segments = pathFromRoot.split(sep);
|
|
if (segments.some((segment) => protectedSegments.has(segment.toLowerCase()))) {
|
|
throw new HarnessError("PROTECTED_PATH", "Protected paths cannot be accessed");
|
|
}
|
|
}
|
|
|
|
async #assertNoSymlinkSegments(candidate: string): Promise<void> {
|
|
this.#assertContained(candidate);
|
|
const pathFromRoot = relative(this.root, candidate);
|
|
if (pathFromRoot === "") {
|
|
const rootMetadata = await lstat(this.root);
|
|
if (rootMetadata.isSymbolicLink()) {
|
|
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Symbolic links are not allowed");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let current = this.root;
|
|
for (const segment of pathFromRoot.split(sep)) {
|
|
current = join(current, segment);
|
|
const metadata = await lstat(current);
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new HarnessError("SYMLINK_NOT_ALLOWED", "Symbolic links are not allowed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isMissingFileError(error: unknown): boolean {
|
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
}
|