1import {
2 buildFunctionArtifact,
3 validateFunctionZip,
4} from "@/Neon/FunctionArtifact";
5import { zipFiles } from "@/Util/zip";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { expect, test } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11
12const scope = { branch: { projectId: "project", branchId: "branch" } };
13
14test.effect(
15 "prebuilt directory and ZIP share a deterministic root-entry artifact",
16 () =>
17 Effect.gen(function* () {
18 const fs = yield* FileSystem.FileSystem;
19 const path = yield* Path.Path;
20 const root = yield* fs.makeTempDirectoryScoped();
21 yield* fs.writeFileString(
22 path.join(root, "index.mjs"),
23 'export default { fetch: () => new Response("ok") };',
24 );
25 const first = yield* buildFunctionArtifact({
26 ...scope,
27 artifact: { directory: root },
28 });
29 const second = yield* buildFunctionArtifact({
30 ...scope,
31 artifact: { directory: root },
32 });
33 expect(second.codeHash).toBe(first.codeHash);
34 const zip = path.join(yield* fs.makeTempDirectoryScoped(), "app.zip");
35 yield* fs.writeFile(zip, first.archive);
36 const prebuilt = yield* buildFunctionArtifact({
37 ...scope,
38 artifact: { zip },
39 });
40 expect(prebuilt.codeHash).toBe(first.codeHash);
41 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
42 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
43);
44
45for (const name of [
46 "../secret",
47 "/secret",
48 ".env",
49 "nested/.env.production",
50 "native.node",
51 "nested//duplicate",
52])
53 test.effect(
54 `rejects unsafe artifact path ${name}`,
55 () =>
56 Effect.gen(function* () {
57 const zip = yield* zipFiles([
58 { path: "index.mjs", content: "export default {};" },
59 { path: name, content: "secret" },
60 ]);
61 const rejected = yield* validateFunctionZip(zip).pipe(
62 Effect.as(false),
63 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
64 );
65 expect(rejected).toBe(true);
66 }),
67 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
68 );
69
70for (const machine of [183, 62]) {
71 test.effect(
72 `Function ZIP validates native ELF architecture ${machine}`,
73 () =>
74 Effect.gen(function* () {
75 const content = yield* Effect.sync(() => {
76 const bytes = new Uint8Array(64);
77 bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
78 bytes[16] = 3;
79 bytes[18] = machine;
80 return bytes;
81 });
82 const zip = yield* zipFiles([
83 { path: "index.mjs", content: "export default {};" },
84 { path: "node_modules/addon/addon.node", content },
85 ]);
86 const accepted = yield* validateFunctionZip(zip).pipe(
87 Effect.as(true),
88 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(false)),
89 );
90 expect(accepted).toBe(machine === 183);
91 }),
92 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
93 );
94}
95
96for (const [name, bytes] of [
97 ["darwin.node", [0xcf, 0xfa, 0xed, 0xfe]],
98 ["windows.dll", [0x4d, 0x5a]],
99 ["library.so.1", [0]],
100] as const) {
101 test.effect(
102 `Function ZIP rejects incompatible native file ${name}`,
103 () =>
104 Effect.gen(function* () {
105 const zip = yield* zipFiles([
106 { path: "index.mjs", content: "export default {};" },
107 {
108 path: name,
109 content: yield* Effect.sync(() => new Uint8Array(bytes)),
110 },
111 ]);
112 expect(
113 yield* validateFunctionZip(zip).pipe(
114 Effect.as(false),
115 Effect.catchTag("FunctionArtifactError", () =>
116 Effect.succeed(true),
117 ),
118 ),
119 ).toBe(true);
120 }),
121 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
122 );
123}
124
125test.effect(
126 "requires index.mjs at the ZIP root",
127 () =>
128 Effect.gen(function* () {
129 const zip = yield* zipFiles([
130 { path: "dist/index.mjs", content: "export default {};" },
131 ]);
132 expect(
133 yield* validateFunctionZip(zip).pipe(
134 Effect.as(false),
135 Effect.catchTag("FunctionArtifactError", () => Effect.succeed(true)),
136 ),
137 ).toBe(true);
138 }),
139 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
140);
141
142for (const tamper of ["local-header", "inflated-size"] as const)
143 test.effect(
144 `rejects ZIP ${tamper} size disagreement`,
145 () =>
146 Effect.gen(function* () {
147 const archive = yield* zipFiles([
148 { path: "index.mjs", content: "export default {};".repeat(1000) },
149 ]);
150 yield* Effect.sync(() => {
151 const view = new DataView(
152 archive.buffer,
153 archive.byteOffset,
154 archive.byteLength,
155 );
156 const directory = view.getUint32(archive.byteLength - 6, true);
157 view.setUint32(22, 1, true);
158 if (tamper === "inflated-size")
159 view.setUint32(directory + 24, 1, true);
160 });
161 expect(
162 yield* validateFunctionZip(archive).pipe(
163 Effect.as(false),
164 Effect.catchTag("FunctionArtifactError", () =>
165 Effect.succeed(true),
166 ),
167 ),
168 ).toBe(true);
169 }),
170 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
171 );
172
173test.effect(
174 "changing a native entrypoint changes uploaded bytes and digest",
175 () =>
176 Effect.gen(function* () {
177 const first = yield* buildFunctionArtifact({
178 ...scope,
179 main: new URL("./fixtures/function-native.ts", import.meta.url).href,
180 isExternal: true,
181 });
182 const second = yield* buildFunctionArtifact({
183 ...scope,
184 main: new URL("./fixtures/function-bare.ts", import.meta.url).href,
185 isExternal: true,
186 });
187 expect(second.codeHash).not.toBe(first.codeHash);
188 const files = yield* validateFunctionZip(second.archive);
189 const entry = yield* Effect.sync(() =>
190 new TextDecoder().decode(files["index.mjs"]),
191 );
192 expect(entry).toContain("bare-v2");
193 expect(entry).not.toContain("native-v1");
194 }).pipe(Effect.provide(NodeServices.layer)),
195 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
196);
197
198test.effect(
199 "native bundle targets Node without a Bun or workerd bootstrap",
200 () =>
201 Effect.gen(function* () {
202 const artifact = yield* buildFunctionArtifact({
203 ...scope,
204 main: new URL("./fixtures/function-native.ts", import.meta.url).href,
205 isExternal: true,
206 });
207 const files = yield* validateFunctionZip(artifact.archive);
208 const entry = yield* Effect.sync(() =>
209 new TextDecoder().decode(files["index.mjs"]),
210 );
211 expect(entry).not.toContain("Bun.serve");
212 expect(entry).not.toContain("cloudflare:workers");
213 expect(entry).not.toContain("workerd");
214 expect(entry).toContain("node:module");
215 }).pipe(Effect.provide(NodeServices.layer)),
216 { tags: ["unit", "provider:neon", "provider:neon:function", "local"] },
217);