import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, normalize, relative, resolve, sep, } from "node:path"; export interface ArtifactProvider { isFile(path: string, options?: ArtifactReadOptions): boolean; readText(path: string, options?: ArtifactReadOptions): string; } export type ArtifactReadOptions = { expandHome?: boolean; }; export type ArtifactBundle = { version: 1; files: Record; }; export type ArtifactLimits = { maxArtifactCount?: number; maxArtifactBytes?: number; maxTotalBytes?: number; }; export type LocalArtifactProviderOptions = ArtifactLimits & { workspace: string; strict?: boolean; }; export type BundleArtifactProviderOptions = ArtifactLimits; export const DEFAULT_ARTIFACT_LIMITS = { maxArtifactCount: 100, maxArtifactBytes: 1024 * 1024, maxTotalBytes: 4 * 1024 * 1024, } as const; function artifactError( message: string, code?: string, ): Error & { code?: string } { return Object.assign(new Error(message), code ? { code } : {}); } function toWorkspacePath(path: string): string { if ( !path || path.includes("\0") || path.includes("\\") || path.startsWith("~") || isAbsolute(path) ) { throw artifactError(`Artifact path must be workspace-relative: ${path}`); } const normalized = normalize(path); if (normalized === ".." || normalized.startsWith(`..${sep}`)) { throw artifactError(`Artifact path escapes the workspace: ${path}`); } return normalized.replace(/^\.\//, ""); } function assertWithinWorkspace(workspace: string, path: string): void { const relation = relative(workspace, path); if ( relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation) ) { throw artifactError(`Artifact path escapes the workspace: ${path}`); } } function assertRealPathWithinWorkspace(workspace: string, path: string): void { let existingPath = path; while (!existsSync(existingPath)) { const parent = dirname(existingPath); if (parent === existingPath) break; existingPath = parent; } assertWithinWorkspace(workspace, realpathSync(existingPath)); } function assertPositiveLimit(name: string, value: number | undefined): void { if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { throw new Error(`${name} must be a non-negative safe integer`); } } class ArtifactBudget { readonly #limits: ArtifactLimits; #count = 0; #bytes = 0; constructor(limits: ArtifactLimits) { assertPositiveLimit("maxArtifactCount", limits.maxArtifactCount); assertPositiveLimit("maxArtifactBytes", limits.maxArtifactBytes); assertPositiveLimit("maxTotalBytes", limits.maxTotalBytes); this.#limits = limits; } add(path: string, content: string): void { const bytes = Buffer.byteLength(content); if ( this.#limits.maxArtifactBytes !== undefined && bytes > this.#limits.maxArtifactBytes ) { throw artifactError( `Artifact ${path} exceeds the ${this.#limits.maxArtifactBytes} byte limit`, ); } if ( this.#limits.maxArtifactCount !== undefined && this.#count + 1 > this.#limits.maxArtifactCount ) { throw artifactError( `Artifact count exceeds the ${this.#limits.maxArtifactCount} limit`, ); } if ( this.#limits.maxTotalBytes !== undefined && this.#bytes + bytes > this.#limits.maxTotalBytes ) { throw artifactError( `Artifact bytes exceed the ${this.#limits.maxTotalBytes} byte limit`, ); } this.#count += 1; this.#bytes += bytes; } } export class LocalArtifactProvider implements ArtifactProvider { readonly #workspace: string; readonly #strict: boolean; readonly #budget: ArtifactBudget; constructor(options: LocalArtifactProviderOptions) { this.#workspace = options.strict ? realpathSync(options.workspace) : resolve(options.workspace); this.#strict = options.strict ?? false; this.#budget = new ArtifactBudget(options); } #resolve(path: string, options: ArtifactReadOptions): string { if (!this.#strict) { return options.expandHome && path.startsWith("~") ? resolve(process.env.HOME ?? "", path.slice(1)) : resolve(this.#workspace, path); } const candidate = resolve(this.#workspace, toWorkspacePath(path)); assertWithinWorkspace(this.#workspace, candidate); assertRealPathWithinWorkspace(this.#workspace, candidate); return candidate; } isFile(path: string, options: ArtifactReadOptions = {}): boolean { const resolved = this.#resolve(path, options); return existsSync(resolved) && statSync(resolved).isFile(); } readText(path: string, options: ArtifactReadOptions = {}): string { const resolved = this.#resolve(path, options); if (this.#strict) { // Resolve again at read time so a symlink swap cannot bypass confinement. assertWithinWorkspace(this.#workspace, realpathSync(resolved)); } const content = readFileSync(resolved, "utf8"); this.#budget.add(path, content); return content; } } export class BundleArtifactProvider implements ArtifactProvider { readonly #files = new Map(); constructor( bundle: ArtifactBundle, options: BundleArtifactProviderOptions = {}, ) { if (bundle.version !== 1) throw new Error("Unsupported artifact bundle version"); const budget = new ArtifactBudget({ ...DEFAULT_ARTIFACT_LIMITS, ...options, }); for (const [path, content] of Object.entries(bundle.files)) { const normalized = toWorkspacePath(path); if (this.#files.has(normalized)) { throw artifactError(`Duplicate artifact path: ${path}`); } budget.add(normalized, content); this.#files.set(normalized, content); } } isFile(path: string): boolean { return this.#files.has(toWorkspacePath(path)); } readText(path: string): string { const normalized = toWorkspacePath(path); const content = this.#files.get(normalized); if (content === undefined) { throw artifactError(`Artifact not found: ${path}`, "ENOENT"); } return content; } } export function createArtifactBundle( files: Record, ): ArtifactBundle { return { version: 1, files: { ...files } }; }