1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import { inflateRawSync } from "node:zlib";
6import * as Bundle from "../Bundle/Bundle.ts";
7import * as TempRoot from "../Bundle/TempRoot.ts";
8import { sha256 } from "../Util/sha256.ts";
9import { moduleExtension } from "../Util/Node.ts";
10import { zipFiles, type ZipFile } from "../Util/zip.ts";
11import type { FunctionProps } from "./Function.ts";
12import { nativeArtifactError } from "./NativeArtifact.ts";
13
14export class FunctionArtifactError extends Data.TaggedError(
15 "FunctionArtifactError",
16)<{ message: string }> {}
17
18const 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. */
37export 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
134export 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});