1/**
2 * INTERNAL — deterministic identity for a Docker build: context + Dockerfile
3 * + platform + build args, with Docker's own semantics (`.dockerignore`
4 * pattern matching, symlink targets, permission bits, empty directories).
5 * Cloud-agnostic — consumed by `AWS.ECR.Image` and `AWS.Lambda.Function`
6 * image packaging; NOT exported from the Docker barrel.
7 */
8
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import * as Result from "effect/Result";
13import * as Stream from "effect/Stream";
14import * as crypto from "node:crypto";
15
16export interface DockerBuildSource {
17 context: string;
18 dockerfile: string;
19 platform: string;
20 buildArgs?: Record<string, string>;
21}
22
23export type DockerBuildHashMode = "all" | "effective";
24
25interface DockerIgnoreRule {
26 ignored: boolean;
27 expression: RegExp;
28}
29
30interface DockerIgnore {
31 content: string;
32 path?: string;
33 rules: ReadonlyArray<DockerIgnoreRule>;
34}
35
36const normalizeRelativePath = (value: string) =>
37 value.replaceAll("\\", "/").replace(/^\.\/+/, "");
38
39const escapeRegExp = (value: string) =>
40 value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
41
42/**
43 * Compile Docker's ordered ignore-pattern form into a path matcher.
44 *
45 * Matching is rooted at the context. Descendants of a matched directory are
46 * handled by checking each path's parents in {@link isDockerIgnored}.
47 */
48const cleanDockerIgnorePath = (value: string) => {
49 const parts: string[] = [];
50 for (const part of value.split("/")) {
51 if (part.length === 0 || part === ".") continue;
52 if (part === "..") {
53 if (parts.length > 0 && parts.at(-1) !== "..") parts.pop();
54 else parts.push(part);
55 } else {
56 parts.push(part);
57 }
58 }
59 return parts.join("/");
60};
61
62const compileDockerIgnoreRule = (raw: string): DockerIgnoreRule | undefined => {
63 if (raw.startsWith("#")) {
64 return undefined;
65 }
66
67 let pattern = raw.trim();
68 if (pattern.length === 0 || pattern === ".") {
69 return undefined;
70 }
71
72 let ignored = true;
73 if (pattern.startsWith("\\!") || pattern.startsWith("\\#")) {
74 pattern = pattern.slice(1);
75 } else if (pattern.startsWith("!")) {
76 ignored = false;
77 pattern = pattern.slice(1).trim();
78 }
79
80 pattern = cleanDockerIgnorePath(
81 pattern
82 .replace(/^\.\/+/, "")
83 .replace(/^\/+/, "")
84 .replace(/\/+$/, ""),
85 );
86 if (pattern.length === 0 || pattern === ".") {
87 return undefined;
88 }
89
90 let body = "";
91 for (let index = 0; index < pattern.length; index++) {
92 const char = pattern[index];
93 if (char === "\\" && pattern[index + 1] !== undefined) {
94 body += escapeRegExp(pattern[++index]);
95 continue;
96 }
97 if (char === "*") {
98 if (pattern[index + 1] === "*") {
99 while (pattern[index + 1] === "*") {
100 index++;
101 }
102 if (pattern[index + 1] === "/") {
103 index++;
104 body += "(?:.*/)?";
105 } else {
106 body += ".*";
107 }
108 } else {
109 body += "[^/]*";
110 }
111 continue;
112 }
113 if (char === "?") {
114 body += "[^/]";
115 continue;
116 }
117 if (char === "[") {
118 const end = pattern.indexOf("]", index + 1);
119 if (end !== -1) {
120 const content = pattern.slice(index + 1, end);
121 const negated = content.startsWith("!") || content.startsWith("^");
122 const members = negated ? content.slice(1) : content;
123 body += `[${negated ? "^" : ""}${members.replaceAll("\\", "\\\\")}]`;
124 index = end;
125 continue;
126 }
127 }
128 body += escapeRegExp(char);
129 }
130
131 return {
132 ignored,
133 expression: new RegExp(`^${body}$`),
134 };
135};
136
137const isDockerIgnored = (
138 relativePath: string,
139 rules: ReadonlyArray<DockerIgnoreRule>,
140) => {
141 const segments = normalizeRelativePath(relativePath).split("/");
142 const candidates = segments.map((_, index) =>
143 segments.slice(0, index + 1).join("/"),
144 );
145 let ignored = false;
146 for (const rule of rules) {
147 if (candidates.some((candidate) => rule.expression.test(candidate))) {
148 ignored = rule.ignored;
149 }
150 }
151 return ignored;
152};
153
154/**
155 * Resolve and validate a Docker build context and Dockerfile.
156 *
157 * Dockerfile paths are relative to the build context unless absolute.
158 */
159export const resolveDockerBuildPaths = Effect.fn(function* (
160 source: Pick<DockerBuildSource, "context" | "dockerfile">,
161) {
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 const context = path.resolve(source.context);
165 const dockerfile = path.isAbsolute(source.dockerfile)
166 ? source.dockerfile
167 : path.resolve(context, source.dockerfile);
168
169 if (!(yield* fs.exists(context))) {
170 return yield* Effect.fail(
171 new Error(`Docker build context does not exist: ${context}`),
172 );
173 }
174 if (!(yield* fs.exists(dockerfile))) {
175 return yield* Effect.fail(
176 new Error(`Dockerfile does not exist: ${dockerfile}`),
177 );
178 }
179
180 return { context, dockerfile };
181});
182
183const resolveDockerIgnore = Effect.fn(function* ({
184 context,
185 dockerfile,
186}: {
187 context: string;
188 dockerfile: string;
189}) {
190 const fs = yield* FileSystem.FileSystem;
191 const path = yield* Path.Path;
192 const dockerfileIgnore = `${dockerfile}.dockerignore`;
193 const contextIgnore = path.join(context, ".dockerignore");
194 const ignoreFile = (yield* fs.exists(dockerfileIgnore))
195 ? dockerfileIgnore
196 : (yield* fs.exists(contextIgnore))
197 ? contextIgnore
198 : undefined;
199
200 if (ignoreFile === undefined) {
201 return undefined;
202 }
203
204 const content = yield* fs.readFileString(ignoreFile);
205 const relativePath = normalizeRelativePath(
206 path.relative(context, ignoreFile),
207 );
208 return {
209 content,
210 path:
211 relativePath === ".." ||
212 relativePath.startsWith("../") ||
213 path.isAbsolute(relativePath)
214 ? undefined
215 : relativePath,
216 rules: content
217 .replace(/^\uFEFF/, "")
218 .split(/\r?\n/)
219 .flatMap((line) => {
220 const rule = compileDockerIgnoreRule(line);
221 return rule === undefined ? [] : [rule];
222 }),
223 } satisfies DockerIgnore;
224});
225
226interface DockerBuildContextSelection {
227 /** Absolute build-context directory. */
228 readonly context: string;
229 /** Absolute Dockerfile path. */
230 readonly dockerfile: string;
231 /** Dockerfile path relative to the context, if it is inside the context. */
232 readonly dockerfilePath: string | undefined;
233 /** Return whether an entry must be sent to the Docker builder. */
234 readonly includes: (relativePath: string) => boolean;
235}
236
237/**
238 * Select the effective files sent for a Docker build.
239 *
240 * Dockerfile-specific ignore files take precedence over `.dockerignore`.
241 * The Dockerfile and selected ignore file stay in the upload even when an
242 * ignore rule matches them, as Docker clients must send both to the builder.
243 */
244export const selectDockerBuildContext = Effect.fn(function* (
245 source: Pick<DockerBuildSource, "context" | "dockerfile">,
246) {
247 const path = yield* Path.Path;
248 const { context, dockerfile } = yield* resolveDockerBuildPaths(source);
249 const dockerignore = yield* resolveDockerIgnore({ context, dockerfile });
250 const relativeDockerfile = normalizeRelativePath(
251 path.relative(context, dockerfile),
252 );
253 const dockerfilePath =
254 relativeDockerfile === ".." ||
255 relativeDockerfile.startsWith("../") ||
256 path.isAbsolute(relativeDockerfile)
257 ? undefined
258 : relativeDockerfile;
259
260 return {
261 context,
262 dockerfile,
263 dockerfilePath,
264 includes: (relativePath: string) => {
265 const normalized = normalizeRelativePath(relativePath);
266 if (normalized === dockerfilePath || normalized === dockerignore?.path) {
267 return true;
268 }
269 return !isDockerIgnored(normalized, dockerignore?.rules ?? []);
270 },
271 } satisfies DockerBuildContextSelection;
272});
273
274/**
275 * Hash a Docker build context together with the Dockerfile, platform, and
276 * build arguments. In `effective` mode, the selected `.dockerignore` is
277 * applied exactly once before files are hashed. Absolute paths do not
278 * participate.
279 */
280export const hashDockerBuildInputs = Effect.fn(function* (
281 source: DockerBuildSource,
282 mode: DockerBuildHashMode,
283) {
284 const fs = yield* FileSystem.FileSystem;
285 const path = yield* Path.Path;
286 const { context, dockerfile } = yield* resolveDockerBuildPaths(source);
287 const dockerignore =
288 mode === "effective"
289 ? yield* resolveDockerIgnore({ context, dockerfile })
290 : undefined;
291 const hasher = yield* Effect.sync(() => crypto.createHash("sha256"));
292
293 yield* Effect.sync(() =>
294 hasher.update(
295 JSON.stringify({
296 platform: source.platform,
297 buildArgs: Object.entries(source.buildArgs ?? {}).sort(([a], [b]) =>
298 a < b ? -1 : a > b ? 1 : 0,
299 ),
300 dockerignore:
301 dockerignore === undefined
302 ? undefined
303 : {
304 content: dockerignore.content,
305 path: dockerignore.path,
306 },
307 }),
308 ),
309 );
310
311 const dockerfileContent = yield* fs.readFile(dockerfile);
312 yield* Effect.sync(() => {
313 hasher.update("Dockerfile\0");
314 hasher.update(dockerfileContent);
315 });
316
317 const entries = yield* fs.readDirectory(context, { recursive: true });
318 for (const entry of entries.sort()) {
319 const normalizedEntry = normalizeRelativePath(entry);
320 if (
321 dockerignore !== undefined &&
322 (normalizedEntry === dockerignore.path ||
323 isDockerIgnored(normalizedEntry, dockerignore.rules))
324 ) {
325 continue;
326 }
327
328 const fullPath = path.join(context, entry);
329 const hashedEntry = mode === "effective" ? normalizedEntry : entry;
330 // FileSystem.stat follows symbolic links, so probe the link itself first.
331 // Docker preserves the link in the build context; hashing the target file
332 // would miss retargets between files with identical contents and metadata.
333 const link = yield* Effect.result(fs.readLink(fullPath));
334 if (Result.isSuccess(link)) {
335 yield* Effect.sync(() =>
336 hasher.update(
337 `${JSON.stringify({
338 path: hashedEntry,
339 type: "SymbolicLink",
340 target: link.success,
341 })}\0`,
342 ),
343 );
344 continue;
345 }
346
347 const info = yield* fs.stat(fullPath);
348
349 // Docker COPY preserves entry types and permission bits, including empty
350 // directories. Include that metadata before any file bytes so changes such
351 // as making a Lambda bootstrap executable invalidate the image tag.
352 yield* Effect.sync(() =>
353 hasher.update(
354 `${JSON.stringify({
355 path: hashedEntry,
356 type: info.type,
357 mode: info.mode & 0o7777,
358 size: info.type === "File" ? String(info.size) : undefined,
359 })}\0`,
360 ),
361 );
362
363 if (info.type === "File") {
364 yield* fs.stream(fullPath).pipe(
365 Stream.runForEach((chunk) =>
366 Effect.sync(() => {
367 hasher.update(chunk);
368 }),
369 ),
370 );
371 }
372 }
373
374 return (yield* Effect.sync(() => hasher.digest("hex"))).slice(0, 32);
375});