227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
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<string, string>();
|
|
|
|
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<string, string>,
|
|
): ArtifactBundle {
|
|
return { version: 1, files: { ...files } };
|
|
}
|