1import * as Effect from "effect/Effect";
2import * as Stream from "effect/Stream";
3import { createHash } from "node:crypto";
4import { constants, type BigIntStats } from "node:fs";
5import { lstat, open, realpath } from "node:fs/promises";
6
7const READ_CHUNK_BYTES = 64 * 1024;
8
9export interface VerifiedFile {
10 readonly _tag: "VerifiedFile" | "ArtifactFile";
11 readonly path: string;
12 readonly size: number;
13 readonly mode: number;
14 readonly identity: {
15 readonly dev: string;
16 readonly ino: string;
17 readonly size: string;
18 readonly mtimeNs: string;
19 readonly ctimeNs: string;
20 };
21}
22
23export interface ArtifactFile extends VerifiedFile {
24 readonly _tag: "ArtifactFile";
25 readonly sha256: string;
26 readonly cleanup: Effect.Effect<void>;
27}
28
29const toError = (message: string, cause: unknown) =>
30 new Error(message, { cause });
31
32const isSameIdentity = (
33 stat: BigIntStats,
34 identity: VerifiedFile["identity"],
35) =>
36 stat.isFile() &&
37 stat.dev.toString() === identity.dev &&
38 stat.ino.toString() === identity.ino &&
39 stat.size.toString() === identity.size &&
40 stat.mtimeNs.toString() === identity.mtimeNs &&
41 stat.ctimeNs.toString() === identity.ctimeNs;
42
43const identityFrom = (stat: BigIntStats): VerifiedFile["identity"] => ({
44 dev: stat.dev.toString(),
45 ino: stat.ino.toString(),
46 size: stat.size.toString(),
47 mtimeNs: stat.mtimeNs.toString(),
48 ctimeNs: stat.ctimeNs.toString(),
49});
50
51const validateLimit = (maxBytes: number) => {
52 if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
53 throw new Error("Artifact maxBytes must be a positive safe integer.");
54 }
55};
56
57const validateStat = (
58 stat: BigIntStats,
59 maxBytes: number,
60 description: string,
61 allowEmpty: boolean,
62) => {
63 if (!stat.isFile()) {
64 throw new Error(`${description} must be a regular file.`);
65 }
66 if (!allowEmpty && stat.size <= 0n) {
67 throw new Error(`${description} must be non-empty.`);
68 }
69 if (
70 stat.size > BigInt(maxBytes) ||
71 stat.size > BigInt(Number.MAX_SAFE_INTEGER)
72 ) {
73 throw new Error(
74 `${description} exceeds the ${maxBytes} byte upload safety limit.`,
75 );
76 }
77};
78
79async function* readVerifiedChunks(
80 artifact: VerifiedFile,
81): AsyncGenerator<Uint8Array> {
82 const handle = await open(
83 artifact.path,
84 constants.O_RDONLY | constants.O_NOFOLLOW,
85 );
86 try {
87 const before = await handle.stat({ bigint: true });
88 if (!isSameIdentity(before, artifact.identity)) {
89 throw new Error(
90 "Prisma deployment artifact changed after it was validated; rebuild or reselect the artifact before deploying.",
91 );
92 }
93
94 let remaining = artifact.size;
95 while (remaining > 0) {
96 const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining));
97 const { bytesRead } = await handle.read(
98 buffer,
99 0,
100 buffer.byteLength,
101 null,
102 );
103 if (bytesRead === 0) {
104 throw new Error(
105 "Prisma deployment artifact was truncated while it was being read.",
106 );
107 }
108 remaining -= bytesRead;
109 yield new Uint8Array(buffer.buffer, buffer.byteOffset, bytesRead);
110 }
111
112 const trailing = Buffer.allocUnsafe(1);
113 const { bytesRead: trailingBytes } = await handle.read(
114 trailing,
115 0,
116 trailing.byteLength,
117 null,
118 );
119 const after = await handle.stat({ bigint: true });
120 if (trailingBytes !== 0 || !isSameIdentity(after, artifact.identity)) {
121 throw new Error(
122 "Prisma deployment artifact changed while it was being read; rebuild or reselect the artifact before deploying.",
123 );
124 }
125 } finally {
126 await handle.close();
127 }
128}
129
130const inspectVerifiedFilePromise = async (
131 inputPath: string,
132 maxBytes: number,
133 options?: {
134 readonly allowEmpty?: boolean;
135 readonly description?: string;
136 },
137) => {
138 validateLimit(maxBytes);
139 const description = options?.description ?? "File";
140 const inputStat = await lstat(inputPath, { bigint: true });
141 if (inputStat.isSymbolicLink()) {
142 throw new Error(
143 `${description} must not be a symbolic link; provide the regular file directly.`,
144 );
145 }
146 validateStat(inputStat, maxBytes, description, options?.allowEmpty ?? false);
147 const canonicalPath = await realpath(inputPath);
148 const stat = await lstat(canonicalPath, { bigint: true });
149 validateStat(stat, maxBytes, description, options?.allowEmpty ?? false);
150 if (!isSameIdentity(stat, identityFrom(inputStat))) {
151 throw new Error(
152 `${description} changed while it was being validated; retry with a stable file.`,
153 );
154 }
155 return {
156 _tag: "VerifiedFile" as const,
157 path: canonicalPath,
158 size: Number(stat.size),
159 mode: Number(stat.mode),
160 identity: identityFrom(stat),
161 } satisfies VerifiedFile;
162};
163
164export const inspectVerifiedFile = (
165 inputPath: string,
166 maxBytes: number,
167 options?: {
168 readonly allowEmpty?: boolean;
169 readonly description?: string;
170 },
171) =>
172 Effect.tryPromise({
173 try: () => inspectVerifiedFilePromise(inputPath, maxBytes, options),
174 catch: (cause) =>
175 cause instanceof Error
176 ? cause
177 : toError("Failed to validate file.", cause),
178 });
179
180export const inspectArtifactFile = (
181 inputPath: string,
182 maxBytes: number,
183 options?: {
184 readonly cleanup?: Effect.Effect<void>;
185 readonly description?: string;
186 },
187) =>
188 Effect.tryPromise({
189 try: async (signal) => {
190 const description = options?.description ?? "Prisma deployment artifact";
191 const verified = await inspectVerifiedFilePromise(inputPath, maxBytes, {
192 description,
193 });
194 const hash = createHash("sha256");
195 for await (const chunk of readVerifiedChunks(verified)) {
196 if (signal.aborted) {
197 throw new Error("Prisma deployment artifact validation was aborted.");
198 }
199 hash.update(chunk);
200 }
201 return {
202 ...verified,
203 _tag: "ArtifactFile" as const,
204 sha256: hash.digest("hex"),
205 cleanup: options?.cleanup ?? Effect.void,
206 } satisfies ArtifactFile;
207 },
208 catch: (cause) =>
209 cause instanceof Error
210 ? cause
211 : toError("Failed to validate Prisma deployment artifact.", cause),
212 });
213
214export const artifactFileStream = (artifact: ArtifactFile) =>
215 Stream.fromAsyncIterable(readVerifiedChunks(artifact), (cause) =>
216 cause instanceof Error
217 ? cause
218 : toError("Failed to stream Prisma deployment artifact.", cause),
219 );
220
221export const verifiedFileChunks = (file: VerifiedFile) =>
222 readVerifiedChunks(file);
223
224export const readArtifactFile = (artifact: ArtifactFile) =>
225 Effect.gen(function* () {
226 const bytes = new Uint8Array(artifact.size);
227 let offset = 0;
228 yield* Stream.runForEach(artifactFileStream(artifact), (chunk) =>
229 Effect.sync(() => {
230 bytes.set(chunk, offset);
231 offset += chunk.byteLength;
232 }),
233 );
234 if (offset !== artifact.size) {
235 return yield* Effect.fail(
236 new Error(
237 "Prisma deployment artifact length changed while it was being read.",
238 ),
239 );
240 }
241 return bytes;
242 });