|
| 1 | +import { Buffer } from "node:buffer" |
| 2 | +import type { ProjectedPluginPackageDescriptor, Runtime, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipePluginPackageProjection, WorkspaceRecipeStep } from "@automattic/wp-codebox-core" |
| 3 | +import { recipeSource } from "../recipe-sources.js" |
| 4 | +import { collectRecipeTypedArtifact } from "./recipe-declared-artifacts.js" |
| 5 | +import type { RecipePhasedPluginInputStage, RecipeRunDeclaredArtifact } from "./recipe-run-types.js" |
| 6 | + |
| 7 | +const MAX_PHASED_PLUGINS = 20 |
| 8 | +const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024 |
| 9 | + |
| 10 | +export class RecipePhasedPluginInputError extends Error { |
| 11 | + readonly code = "recipe-phased-plugin-input-failed" |
| 12 | + |
| 13 | + constructor(message: string, readonly stage: RecipePhasedPluginInputStage, readonly context: Record<string, unknown> = {}, cause?: unknown) { |
| 14 | + super(message, cause === undefined ? undefined : { cause }) |
| 15 | + this.name = "RecipePhasedPluginInputError" |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +export async function collectPhasedPluginArtifact(recipe: WorkspaceRecipe, step: WorkspaceRecipeStep, runtime: Runtime): Promise<RecipeRunDeclaredArtifact> { |
| 20 | + const artifactName = step.pluginInput?.artifact |
| 21 | + const index = (recipe.artifacts?.typed ?? []).findIndex((artifact) => artifact.name === artifactName) |
| 22 | + const declaration = index >= 0 ? recipe.artifacts?.typed?.[index] : undefined |
| 23 | + if (!declaration) throw new RecipePhasedPluginInputError("Phased plugin input references an unknown typed artifact.", "collect", { artifact: artifactName }) |
| 24 | + const artifact = await collectRecipeTypedArtifact(runtime, declaration, index, declaration.path, { redact: false }) |
| 25 | + if (artifact.status !== "collected" || artifact.parsedJson === undefined) { |
| 26 | + throw new RecipePhasedPluginInputError("Phased plugin input artifact could not be collected as JSON.", "collect", { artifact: artifactName, status: artifact.status }) |
| 27 | + } |
| 28 | + return artifact |
| 29 | +} |
| 30 | + |
| 31 | +export function projectPhasedPluginPackages(input: unknown, projection: WorkspaceRecipePluginPackageProjection): ProjectedPluginPackageDescriptor[] { |
| 32 | + const selected = pointerValue(input, projection.items) |
| 33 | + if (!selected.found || !Array.isArray(selected.value)) throw new RecipePhasedPluginInputError("Phased plugin package items pointer must resolve to an array.", "project", { pointer: projection.items }) |
| 34 | + if (selected.value.length > MAX_PHASED_PLUGINS) throw new RecipePhasedPluginInputError(`Phased plugin input exceeds the ${MAX_PHASED_PLUGINS}-plugin bound.`, "project") |
| 35 | + const resolver = projection.resolver ?? "immutable-archive" |
| 36 | + const slugs = new Set<string>() |
| 37 | + return selected.value.map((item, index) => { |
| 38 | + const slug = projectedString(item, projection.map.slug, `items[${index}].slug`, true) |
| 39 | + const source = projectedString(item, projection.map.source, `items[${index}].source`, resolver === "immutable-archive") |
| 40 | + const sha256 = projectedString(item, projection.map.sha256, `items[${index}].sha256`, resolver === "immutable-archive") |
| 41 | + const pluginFile = projectedString(item, projection.map.pluginFile, `items[${index}].pluginFile`, false) |
| 42 | + const activate = projectedBoolean(item, projection.map.activate, `items[${index}].activate`) |
| 43 | + const loadAs = projectedString(item, projection.map.loadAs, `items[${index}].loadAs`, false) |
| 44 | + if (!/^[a-z0-9][a-z0-9-_]*$/i.test(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug is invalid.`, "project", { slug }) |
| 45 | + if (slugs.has(slug)) throw new RecipePhasedPluginInputError(`items[${index}].slug duplicates ${slug}.`, "project", { slug }) |
| 46 | + if (sha256 && !/^[a-f0-9]{64}$/i.test(sha256)) throw new RecipePhasedPluginInputError(`items[${index}].sha256 must be a 64-character hexadecimal digest.`, "project") |
| 47 | + if (pluginFile && (!pluginFile.startsWith(`${slug}/`) || !/^[^/][^:]*\.php$/.test(pluginFile) || pluginFile.includes(".."))) throw new RecipePhasedPluginInputError(`items[${index}].pluginFile must stay under ${slug}/.`, "project") |
| 48 | + if (loadAs && loadAs !== "plugin") throw new RecipePhasedPluginInputError(`items[${index}].loadAs must be plugin.`, "project") |
| 49 | + if (source) { |
| 50 | + try { |
| 51 | + if (recipeSource(source, sha256).type === "local") throw new Error("local source") |
| 52 | + } catch (error) { |
| 53 | + throw new RecipePhasedPluginInputError(`items[${index}].source must be an HTTPS plugin archive.`, "project", { source }, error) |
| 54 | + } |
| 55 | + } |
| 56 | + slugs.add(slug) |
| 57 | + return { slug, ...(source ? { source } : {}), ...(sha256 ? { sha256: sha256.toLowerCase() } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate !== undefined ? { activate } : {}), loadAs: "plugin" } |
| 58 | + }) |
| 59 | +} |
| 60 | + |
| 61 | +export async function resolvePhasedPluginPackages(descriptors: ProjectedPluginPackageDescriptor[], projection: WorkspaceRecipePluginPackageProjection, fetcher = fetch): Promise<WorkspaceRecipeExtraPlugin[]> { |
| 62 | + const resolver = projection.resolver ?? "immutable-archive" |
| 63 | + if (resolver === "immutable-archive") return descriptors.map((descriptor) => ({ ...descriptor, activate: descriptor.activate !== false })) as WorkspaceRecipeExtraPlugin[] |
| 64 | + const plugins: WorkspaceRecipeExtraPlugin[] = [] |
| 65 | + for (const descriptor of descriptors) { |
| 66 | + const infoUrl = new URL("https://api.wordpress.org/plugins/info/1.2/") |
| 67 | + infoUrl.searchParams.set("action", "plugin_information") |
| 68 | + infoUrl.searchParams.set("request[slug]", descriptor.slug) |
| 69 | + const response = await fetcher(infoUrl, { redirect: "error", signal: AbortSignal.timeout(30_000) }) |
| 70 | + if (!response.ok || !/^application\/json\b/i.test(response.headers.get("content-type") || "")) throw new RecipePhasedPluginInputError(`WordPress.org plugin info failed for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, status: response.status }) |
| 71 | + const contentLength = Number(response.headers.get("content-length") || 0) |
| 72 | + if (contentLength > MAX_REGISTRY_RESPONSE_BYTES) throw new RecipePhasedPluginInputError(`WordPress.org plugin info exceeded the response bound for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, content_length: contentLength }) |
| 73 | + const responseText = await response.text() |
| 74 | + if (Buffer.byteLength(responseText) > MAX_REGISTRY_RESPONSE_BYTES) throw new RecipePhasedPluginInputError(`WordPress.org plugin info exceeded the response bound for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }) |
| 75 | + let info: { version?: unknown; download_link?: unknown } |
| 76 | + try { info = JSON.parse(responseText) as typeof info } catch (error) { throw new RecipePhasedPluginInputError(`WordPress.org plugin info was malformed for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }, error) } |
| 77 | + const version = typeof info.version === "string" ? info.version : "" |
| 78 | + const source = typeof info.download_link === "string" ? info.download_link : "" |
| 79 | + let sourceUrl: URL |
| 80 | + try { sourceUrl = new URL(source) } catch (error) { throw new RecipePhasedPluginInputError(`WordPress.org returned an invalid package URL for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug }, error) } |
| 81 | + if (sourceUrl.protocol !== "https:" || sourceUrl.hostname !== "downloads.wordpress.org" || !sourceUrl.pathname.startsWith("/plugin/") || !version || !/^\d[0-9A-Za-z._+-]*$/.test(version)) throw new RecipePhasedPluginInputError(`WordPress.org returned an invalid immutable package for ${descriptor.slug}.`, "resolve", { slug: descriptor.slug, version, source }) |
| 82 | + plugins.push({ |
| 83 | + source: sourceUrl.toString(), |
| 84 | + slug: descriptor.slug, |
| 85 | + ...(descriptor.pluginFile ? { pluginFile: descriptor.pluginFile } : {}), |
| 86 | + activate: descriptor.activate !== false, |
| 87 | + loadAs: "plugin", |
| 88 | + metadata: { phased_input: { resolver, version, source_url: sourceUrl.toString() } }, |
| 89 | + }) |
| 90 | + } |
| 91 | + return plugins |
| 92 | +} |
| 93 | + |
| 94 | +function projectedString(item: unknown, pointer: string | undefined, field: string, required: boolean): string { |
| 95 | + if (!pointer) { |
| 96 | + if (required) throw new RecipePhasedPluginInputError(`${field} mapping is required.`, "project") |
| 97 | + return "" |
| 98 | + } |
| 99 | + const selected = pointerValue(item, pointer) |
| 100 | + if (!selected.found || typeof selected.value !== "string" || (required && !selected.value)) throw new RecipePhasedPluginInputError(`${field} must resolve to${required ? " a non-empty" : ""} string.`, "project", { pointer }) |
| 101 | + return selected.value |
| 102 | +} |
| 103 | + |
| 104 | +function projectedBoolean(item: unknown, pointer: string | undefined, field: string): boolean | undefined { |
| 105 | + if (!pointer) return undefined |
| 106 | + const selected = pointerValue(item, pointer) |
| 107 | + if (!selected.found || typeof selected.value !== "boolean") throw new RecipePhasedPluginInputError(`${field} must resolve to a boolean.`, "project", { pointer }) |
| 108 | + return selected.value |
| 109 | +} |
| 110 | + |
| 111 | +function pointerValue(value: unknown, pointer: string): { found: boolean; value?: unknown } { |
| 112 | + if (pointer === "") return { found: true, value } |
| 113 | + if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) return { found: false } |
| 114 | + let current = value |
| 115 | + for (const encoded of pointer.slice(1).split("/")) { |
| 116 | + const segment = encoded.replace(/~1/g, "/").replace(/~0/g, "~") |
| 117 | + if (Array.isArray(current)) { |
| 118 | + if (!/^(0|[1-9]\d*)$/.test(segment) || Number(segment) >= current.length) return { found: false } |
| 119 | + current = current[Number(segment)] |
| 120 | + } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) current = (current as Record<string, unknown>)[segment] |
| 121 | + else return { found: false } |
| 122 | + } |
| 123 | + return { found: true, value: current } |
| 124 | +} |
0 commit comments