import { env } from "cloudflare:workers"; import { GitStore } from "../git/store"; import { objectKey, STATIC_LIMITS } from "./config"; import type { ManifestFile } from "./config"; export type PlannedFile = { path: string; sha: string }; export type CollectedFiles = Record; const TAR_BLOCK = 512; const encoder = new TextEncoder(); const decoder = new TextDecoder(); export class DeployError extends Error {} function normalizeFolder(folder: string): string { return folder.replace(/^\/+|\/+$/g, ""); } /** Normalizes an archive path relative to `folder`; null when outside it or unsafe. */ function relativePath(raw: string, folder: string): string | null { const parts = raw .replace(/^\.?\/+/, "") .split("/") .filter((part) => part && part !== "."); if (parts.some((part) => part === "..")) return null; const path = parts.join("/"); if (!folder) return path || null; return path.startsWith(`${folder}/`) ? path.slice(folder.length + 1) : null; } /** Lists the files of a branch folder, bounded by the deploy file cap. */ export async function planGitFiles( artifactsName: string, sha: string, folder: string, maxFiles: number = STATIC_LIMITS.maxFiles, ): Promise { const store = await GitStore.open(artifactsName); try { const commit = await store.commit(sha); if (!commit) throw new DeployError("Commit not found"); const root = normalizeFolder(folder); const entry = await store.entryAtPath(commit.treeSha, root); if (!entry || entry.type !== "tree") throw new DeployError(`Folder "/${root}" not found at ${sha.slice(0, 7)}`); const { entries, truncated } = await store.walk(entry.sha, maxFiles + 1, { withContents: true }); if (truncated) throw new DeployError(`Sites are limited to ${maxFiles} files`); return entries .filter((item) => item.type === "blob" || item.type === "exec") .map((item) => ({ path: item.path, sha: item.sha })); } finally { store.close(); } } /** * Reused objects older than this are written again, so the garbage collector (gc.ts), which only sweeps objects * older than its grace period, can never delete one a deploy in progress relies on. */ const REFRESH_MS = 3 * 24 * 60 * 60 * 1000; function isFresh(object: R2Object): boolean { return Date.now() - object.uploaded.getTime() < REFRESH_MS; } async function ensureObject(key: string, bytes: Uint8Array): Promise { const existing = await env.BUCKET.head(key); if (!existing || !isFresh(existing)) await env.BUCKET.put(key, bytes); } /** Copies git blobs into content-addressed R2 objects, skipping fresh objects that already exist. */ export async function uploadGitBatch(artifactsName: string, batch: PlannedFile[]): Promise { const store = await GitStore.open(artifactsName); const out: CollectedFiles = {}; try { for (const file of batch) { const key = objectKey(file.sha); const existing = await env.BUCKET.head(key); if (existing && isFresh(existing)) { out[file.path] = [file.sha, existing.size]; continue; } const bytes = await store.blob(file.sha); if (!bytes) throw new DeployError(`Missing blob for ${file.path}`); if (bytes.byteLength > STATIC_LIMITS.maxFileBytes) throw new DeployError(`${file.path} exceeds the per-file size limit`); await env.BUCKET.put(key, bytes); out[file.path] = [file.sha, bytes.byteLength]; } return out; } finally { store.close(); } } async function gitBlobSha(bytes: Uint8Array): Promise { const header = encoder.encode(`blob ${bytes.byteLength}\0`); const buffer = new Uint8Array(header.byteLength + bytes.byteLength); buffer.set(header); buffer.set(bytes, header.byteLength); const digest = await crypto.subtle.digest("SHA-1", buffer); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } /** Pull-based byte reader over a stream so tar entries can be consumed without buffering the archive. */ class StreamReader { private chunks: Uint8Array[] = []; private buffered = 0; constructor(private readonly reader: ReadableStreamDefaultReader) {} async read(length: number): Promise | null> { while (this.buffered < length) { const next = await this.reader.read(); if (next.done) return null; this.chunks.push(next.value); this.buffered += next.value.byteLength; } const out = new Uint8Array(length); let offset = 0; while (offset < length) { const chunk = this.chunks[0]; const take = Math.min(chunk.byteLength, length - offset); out.set(chunk.subarray(0, take), offset); offset += take; if (take === chunk.byteLength) this.chunks.shift(); else this.chunks[0] = chunk.subarray(take); } this.buffered -= length; return out; } } function field(header: Uint8Array, offset: number, length: number): string { const raw = header.subarray(offset, offset + length); const end = raw.indexOf(0); return decoder.decode(end === -1 ? raw : raw.subarray(0, end)); } function paxPath(bytes: Uint8Array): string | null { for (const line of decoder.decode(bytes).split("\n")) { const match = /^\d+ path=(.*)$/.exec(line); if (match) return match[1]; } return null; } /** Extracts a gzipped tar workflow artifact into content-addressed R2 objects. */ export async function extractArtifact( r2Key: string, folder: string, limits: { maxFiles: number; maxTotalBytes: number } = STATIC_LIMITS, ): Promise { const object = await env.BUCKET.get(r2Key); if (!object) throw new DeployError("Artifact has expired"); if (object.size > STATIC_LIMITS.maxArtifactBytes) throw new DeployError("Artifact is too large to deploy"); const reader = new StreamReader(object.body.pipeThrough(new DecompressionStream("gzip")).getReader()); const root = normalizeFolder(folder); const out: CollectedFiles = {}; let count = 0; let total = 0; let longName: string | null = null; try { for (;;) { const header = await reader.read(TAR_BLOCK); if (!header || header.every((byte) => byte === 0)) break; const size = Number.parseInt(field(header, 124, 12).trim() || "0", 8); const type = field(header, 156, 1) || "0"; if (!Number.isFinite(size) || size < 0) throw new DeployError("Corrupt artifact archive"); if (size > STATIC_LIMITS.maxFileBytes && (type === "0" || type === "7")) { throw new DeployError("A file in the artifact exceeds the per-file size limit"); } total += size; if (total > limits.maxTotalBytes) throw new DeployError("Artifact exceeds the total site size limit"); const padded = Math.ceil(size / TAR_BLOCK) * TAR_BLOCK; const body = padded > 0 ? await reader.read(padded) : new Uint8Array(0); if (!body) throw new DeployError("Truncated artifact archive"); const content = body.subarray(0, size); if (type === "L") { longName = field(content, 0, size); continue; } if (type === "x") { longName = paxPath(content) ?? longName; continue; } const prefix = field(header, 345, 155); const name = longName ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100)); longName = null; if (type !== "0" && type !== "7") continue; const path = relativePath(name, root); if (!path) continue; count += 1; if (count > limits.maxFiles) throw new DeployError(`Sites are limited to ${limits.maxFiles} files`); const bytes = new Uint8Array(content); const sha = await gitBlobSha(bytes); await ensureObject(objectKey(sha), bytes); out[path] = [sha, size]; } } catch (error) { if (error instanceof DeployError) throw error; throw new DeployError("Artifact is not a valid .tar.gz archive"); } return out; } /** Stores one file as a content-addressed object, as artifact extraction does. */ export async function storeFile(bytes: Uint8Array): Promise { if (bytes.byteLength > STATIC_LIMITS.maxFileBytes) throw new DeployError("A file exceeds the per-file size limit"); const sha = await gitBlobSha(bytes); await ensureObject(objectKey(sha), bytes); return [sha, bytes.byteLength]; } export function totalBytes(files: CollectedFiles): number { return Object.values(files).reduce((sum, [, size]) => sum + size, 0); }