| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import { inflateRawSync } from "node:zlib"; |
| 6 | import * as Bundle from "../Bundle/Bundle.ts"; |
| 7 | import * as TempRoot from "../Bundle/TempRoot.ts"; |
| 8 | import { sha256 } from "../Util/sha256.ts"; |
| 9 | import { moduleExtension } from "../Util/Node.ts"; |
| 10 | import { zipFiles, type ZipFile } from "../Util/zip.ts"; |
| 11 | import type { FunctionProps } from "./Function.ts"; |
| 12 | import { nativeArtifactError } from "./NativeArtifact.ts"; |
| 13 | |
| 14 | export class FunctionArtifactError extends Data.TaggedError( |
| 15 | "FunctionArtifactError", |
| 16 | )<{ message: string }> {} |
| 17 | |
| 18 | const safePath = (name: string) => |
| 19 | name.length > 0 && |
| 20 | !name.startsWith("/") && |
| 21 | !name.includes("\\") && |
| 22 | !name |
| 23 | .replace(/\/$/, "") |
| 24 | .split("/") |
| 25 | .some( |
| 26 | (part) => |
| 27 | part.length === 0 || |
| 28 | part === ".." || |
| 29 | part === "." || |
| 30 | part.startsWith(".env") || |
| 31 | part === ".git" || |
| 32 | part === ".alchemy", |
| 33 | ) && |
| 34 | !/^[a-z]:/i.test(name); |
| 35 | |
| 36 | /** Validate before extracting, including ZIP Unix mode bits and decompressed size. */ |
| 37 | export const validateFunctionZip = (bytes: Uint8Array) => |
| 38 | Effect.try({ |
| 39 | try: () => { |
| 40 | if (bytes.byteLength > 100 * 1024 * 1024) |
| 41 | throw new Error("Archive exceeds 100 MiB safety limit"); |
| 42 | const view = new DataView( |
| 43 | bytes.buffer, |
| 44 | bytes.byteOffset, |
| 45 | bytes.byteLength, |
| 46 | ); |
| 47 | let end = bytes.length - 22; |
| 48 | for (; end >= Math.max(0, bytes.length - 65557); end--) |
| 49 | if (view.getUint32(end, true) === 0x06054b50) break; |
| 50 | if (end < 0 || view.getUint32(end, true) !== 0x06054b50) |
| 51 | throw new Error("Invalid ZIP directory"); |
| 52 | const count = view.getUint16(end + 10, true); |
| 53 | let offset = view.getUint32(end + 16, true); |
| 54 | let size = 0; |
| 55 | const files: Record<string, Uint8Array> = Object.create(null); |
| 56 | for (let i = 0; i < count; i++) { |
| 57 | if (view.getUint32(offset, true) !== 0x02014b50) |
| 58 | throw new Error("Invalid ZIP entry"); |
| 59 | const length = view.getUint16(offset + 28, true); |
| 60 | const name = new TextDecoder().decode( |
| 61 | bytes.subarray(offset + 46, offset + 46 + length), |
| 62 | ); |
| 63 | const mode = view.getUint32(offset + 38, true) >>> 16; |
| 64 | if (!safePath(name) || name in files || (mode & 0xf000) === 0xa000) |
| 65 | throw new Error("Unsafe or duplicate archive entry"); |
| 66 | const flags = view.getUint16(offset + 8, true); |
| 67 | const method = view.getUint16(offset + 10, true); |
| 68 | const compressedSize = view.getUint32(offset + 20, true); |
| 69 | const expandedSize = view.getUint32(offset + 24, true); |
| 70 | if ((flags & 1) !== 0 || (method !== 0 && method !== 8)) |
| 71 | throw new Error("Unsupported ZIP encoding"); |
| 72 | if (size + expandedSize > 250 * 1024 * 1024) |
| 73 | throw new Error("Expanded archive exceeds 250 MiB safety limit"); |
| 74 | const local = view.getUint32(offset + 42, true); |
| 75 | if ( |
| 76 | view.getUint32(local, true) !== 0x04034b50 || |
| 77 | view.getUint16(local + 6, true) !== flags || |
| 78 | view.getUint16(local + 8, true) !== method |
| 79 | ) |
| 80 | throw new Error("ZIP local header mismatch"); |
| 81 | const localCompressed = view.getUint32(local + 18, true); |
| 82 | const localExpanded = view.getUint32(local + 22, true); |
| 83 | if ( |
| 84 | (((flags & 8) === 0 || localCompressed !== 0) && |
| 85 | localCompressed !== compressedSize) || |
| 86 | (((flags & 8) === 0 || localExpanded !== 0) && |
| 87 | localExpanded !== expandedSize) |
| 88 | ) |
| 89 | throw new Error("ZIP size mismatch"); |
| 90 | const localNameLength = view.getUint16(local + 26, true); |
| 91 | if ( |
| 92 | new TextDecoder().decode( |
| 93 | bytes.subarray(local + 30, local + 30 + localNameLength), |
| 94 | ) !== name |
| 95 | ) |
| 96 | throw new Error("ZIP entry name mismatch"); |
| 97 | const start = |
| 98 | local + 30 + localNameLength + view.getUint16(local + 28, true); |
| 99 | if (start + compressedSize > view.getUint32(end + 16, true)) |
| 100 | throw new Error("ZIP entry overlaps directory"); |
| 101 | const compressed = bytes.subarray(start, start + compressedSize); |
| 102 | const content = |
| 103 | method === 0 |
| 104 | ? compressed |
| 105 | : inflateRawSync(compressed, { maxOutputLength: expandedSize + 1 }); |
| 106 | if (content.byteLength !== expandedSize) |
| 107 | throw new Error("ZIP expanded size mismatch"); |
| 108 | const nativeError = nativeArtifactError(name, content); |
| 109 | if (nativeError) throw new Error(nativeError); |
| 110 | size += content.byteLength; |
| 111 | files[name] = content; |
| 112 | offset += |
| 113 | 46 + |
| 114 | length + |
| 115 | view.getUint16(offset + 30, true) + |
| 116 | view.getUint16(offset + 32, true); |
| 117 | } |
| 118 | if (!("index.mjs" in files)) |
| 119 | throw new Error("Artifact requires root index.mjs"); |
| 120 | if ( |
| 121 | offset !== |
| 122 | view.getUint32(end + 16, true) + view.getUint32(end + 12, true) |
| 123 | ) |
| 124 | throw new Error("ZIP directory mismatch"); |
| 125 | return files; |
| 126 | }, |
| 127 | catch: () => |
| 128 | new FunctionArtifactError({ |
| 129 | message: |
| 130 | "Invalid Function ZIP: require root index.mjs, safe relative paths, no symlinks/secrets, Linux ARM64 native files, and bounded size", |
| 131 | }), |
| 132 | }); |
| 133 | |
| 134 | export const buildFunctionArtifact = Effect.fn(function* ( |
| 135 | props: FunctionProps, |
| 136 | ) { |
| 137 | const fs = yield* FileSystem.FileSystem; |
| 138 | const path = yield* Path.Path; |
| 139 | let files: ZipFile[] = []; |
| 140 | if (props.artifact?.zip !== undefined) { |
| 141 | const archive = yield* fs.readFile(props.artifact.zip); |
| 142 | yield* validateFunctionZip(archive); |
| 143 | return { archive, codeHash: yield* sha256(archive) }; |
| 144 | } |
| 145 | if (props.artifact?.directory !== undefined) { |
| 146 | const root = yield* fs.realPath(props.artifact.directory); |
| 147 | const walk: ( |
| 148 | relative: string, |
| 149 | ) => Effect.Effect< |
| 150 | void, |
| 151 | FunctionArtifactError | import("effect/PlatformError").PlatformError |
| 152 | > = Effect.fn(function* (relative) { |
| 153 | for (const entry of (yield* fs.readDirectory( |
| 154 | path.join(root, relative), |
| 155 | )).sort()) { |
| 156 | const name = relative ? `${relative}/${entry}` : entry; |
| 157 | if (!safePath(name)) |
| 158 | return yield* new FunctionArtifactError({ |
| 159 | message: `Unsafe Function artifact entry: ${name}`, |
| 160 | }); |
| 161 | const absolute = path.join(root, name); |
| 162 | if ((yield* fs.realPath(absolute)) !== absolute) |
| 163 | return yield* new FunctionArtifactError({ |
| 164 | message: "Function artifacts must not contain symlinks", |
| 165 | }); |
| 166 | const info = yield* fs.stat(absolute); |
| 167 | if (info.type === "Directory") yield* walk(name); |
| 168 | else if (info.type === "File") |
| 169 | files.push({ path: name, content: yield* fs.readFile(absolute) }); |
| 170 | else |
| 171 | return yield* new FunctionArtifactError({ |
| 172 | message: "Function artifacts support regular files only", |
| 173 | }); |
| 174 | } |
| 175 | }); |
| 176 | yield* walk(""); |
| 177 | } else { |
| 178 | if (!props.main) |
| 179 | return yield* new FunctionArtifactError({ |
| 180 | message: "Specify main or artifact", |
| 181 | }); |
| 182 | const realMain = yield* TempRoot.resolveMainPath(props.main); |
| 183 | const virtual = yield* Bundle.virtualEntryPlugin; |
| 184 | const bridge = yield* Effect.sync(() => |
| 185 | import.meta.resolve( |
| 186 | `./FunctionBridge${moduleExtension(import.meta.url)}`, |
| 187 | ), |
| 188 | ); |
| 189 | const output = yield* Bundle.build( |
| 190 | { |
| 191 | ...props.bundle?.input, |
| 192 | input: realMain, |
| 193 | cwd: yield* TempRoot.findCwdForBundle(realMain), |
| 194 | platform: "node", |
| 195 | resolve: { |
| 196 | ...props.bundle?.input?.resolve, |
| 197 | conditionNames: [...Bundle.NODE_CONDITION_NAMES], |
| 198 | }, |
| 199 | plugins: [ |
| 200 | props.bundle?.input?.plugins, |
| 201 | props.isExternal |
| 202 | ? undefined |
| 203 | : virtual( |
| 204 | (entry) => |
| 205 | `import { makeFunctionBridge } from ${JSON.stringify(bridge)};\nimport entrypoint from ${JSON.stringify(entry)};\nexport default makeFunctionBridge(entrypoint);`, |
| 206 | ), |
| 207 | ], |
| 208 | }, |
| 209 | { |
| 210 | ...props.bundle?.output, |
| 211 | format: "esm", |
| 212 | entryFileNames: "index.mjs", |
| 213 | codeSplitting: false, |
| 214 | banner: `import { createRequire as __alchemyCreateRequire } from "node:module";\nconst require = __alchemyCreateRequire(import.meta.url);`, |
| 215 | }, |
| 216 | props.bundle, |
| 217 | ); |
| 218 | files = output.files.map(({ path, content }) => ({ path, content })); |
| 219 | } |
| 220 | if (!files.some((file) => file.path === "index.mjs")) |
| 221 | return yield* new FunctionArtifactError({ |
| 222 | message: "Function artifact requires root index.mjs", |
| 223 | }); |
| 224 | const archive = yield* zipFiles(files); |
| 225 | yield* validateFunctionZip(archive); |
| 226 | return { archive, codeHash: yield* sha256(archive) }; |
| 227 | }); |