1// @effect-diagnostics nodeBuiltinImport:off - Fingerprints unsigned release files at the packaging boundary.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as Schema from "effect/Schema";
6
7import { fromYaml } from "@t3tools/shared/schemaYaml";
8
9import { isRuntimeExternalCliDependency } from "./cli-external-packages.ts";
10
11/** Release bookkeeping and renderer files cannot change the running backend's compatibility. */
12export async function fingerprintForkRuntime(
13 directory: string,
14 input: {
15 platform: string;
16 arch: string;
17 signingFiles: readonly string[];
18 shellOnly?: boolean;
19 serverDirectory?: string;
20 },
21): Promise<string> {
22 const hash = NodeCrypto.createHash("sha256").update(
23 JSON.stringify({ platform: input.platform, arch: input.arch }),
24 );
25 const visit = async (
26 root: string,
27 relative: string,
28 ancestors: ReadonlySet<string>,
29 ): Promise<void> => {
30 if (
31 relative === "apps/server/dist/client" ||
32 relative === "apps/desktop/prod-resources/windows-server" ||
33 /^apps\/(?:server\/dist|desktop\/dist-electron)\/.*\.(?:mjs|cjs|js)\.map$/.test(relative)
34 )
35 return;
36 if (
37 input.shellOnly &&
38 (relative === "node_modules" ||
39 relative === "apps/server" ||
40 (relative.startsWith("apps/desktop/dist-electron/") &&
41 !["boot.cjs", "compileCache.cjs"].includes(NodePath.basename(relative))))
42 )
43 return;
44 const file = NodePath.join(root, relative);
45 const real = await NodeFSP.realpath(file);
46 const stat = await NodeFSP.stat(real);
47 if (stat.isDirectory()) {
48 if (ancestors.has(real)) return;
49 const next = new Set([...ancestors, real]);
50 for (const name of (await NodeFSP.readdir(real)).sort()) {
51 if (name === ".bin" || name === ".modules.yaml" || name === ".pnpm-workspace-state-v1.json")
52 continue;
53 await visit(root, NodePath.posix.join(relative, name), next);
54 }
55 return;
56 }
57 hash
58 .update(relative)
59 .update("\0")
60 .update(String(stat.mode & 0o777))
61 .update("\0")
62 .update(await NodeFSP.readFile(real))
63 .update("\0");
64 };
65
66 const metadata: Record<string, unknown> = JSON.parse(
67 await NodeFSP.readFile(NodePath.join(directory, "package.json"), "utf8"),
68 );
69 delete metadata.version;
70 delete metadata.buildVersion;
71 delete metadata.t3codeCommitHash;
72 if (input.shellOnly) delete metadata.dependencies;
73 let normalized = JSON.stringify(metadata).replaceAll(directory, "<app>");
74 for (const [index, file] of input.signingFiles.entries()) {
75 normalized = normalized.replaceAll(file, `<signing-${index}>`);
76 hash.update(await NodeFSP.readFile(file));
77 }
78 hash.update(normalized);
79 for (const root of input.shellOnly ? ["apps"] : ["apps", "node_modules"])
80 await visit(directory, root, new Set());
81 if (!input.shellOnly && input.serverDirectory !== undefined) {
82 hash.update("server-sidecar\0");
83 for (const root of ["apps", "node_modules"])
84 await visit(input.serverDirectory, root, new Set());
85 }
86 return hash.digest("hex");
87}
88
89const LockedDependencies = Schema.Record(Schema.String, Schema.String);
90const RuntimeLock = fromYaml(
91 Schema.Struct({
92 importers: Schema.Record(
93 Schema.String,
94 Schema.Struct({
95 dependencies: Schema.optionalKey(
96 Schema.Record(Schema.String, Schema.Struct({ version: Schema.String })),
97 ),
98 }),
99 ),
100 packages: Schema.Record(Schema.String, Schema.Unknown),
101 snapshots: Schema.Record(
102 Schema.String,
103 Schema.Struct({
104 dependencies: Schema.optionalKey(LockedDependencies),
105 optionalDependencies: Schema.optionalKey(LockedDependencies),
106 }),
107 ),
108 }),
109);
110const decodeRuntimeLock = Schema.decodeSync(RuntimeLock);
111
112/** Matches portable server code to the image's complete locked external dependency closure. */
113export async function fingerprintForkServerRuntime(directory: string): Promise<string> {
114 const lock = decodeRuntimeLock(
115 await NodeFSP.readFile(NodePath.join(directory, "pnpm-lock.yaml"), "utf8"),
116 );
117 const roots = Object.entries(lock.importers["apps/server"]?.dependencies ?? {})
118 .filter(([name]) => isRuntimeExternalCliDependency(name))
119 .map(([name, { version }]) => `${name}@${version}`)
120 .sort();
121 if (roots.length === 0) throw new Error("The server runtime has no locked dependencies.");
122
123 const visited = new Set<string>();
124 const visit = (key: string): void => {
125 if (visited.has(key)) return;
126 visited.add(key);
127 const snapshot = lock.snapshots[key];
128 if (!snapshot) throw new Error(`Missing locked runtime dependency: ${key}.`);
129 for (const [name, version] of Object.entries({
130 ...snapshot.dependencies,
131 ...snapshot.optionalDependencies,
132 })) {
133 visit(`${name}@${version}`);
134 }
135 };
136 roots.forEach(visit);
137
138 const closure = [...visited].sort().map((key) => {
139 const packageKey = key.replace(/\(.*$/, "");
140 return [key, lock.packages[packageKey], lock.snapshots[key]];
141 });
142 const hash = NodeCrypto.createHash("sha256").update(JSON.stringify({ roots, closure }));
143 for (const file of [
144 "scripts/container-runtime.ts",
145 "infra/container/knife",
146 "packages/shared/src/forkUpdateDirectory.ts",
147 ]) {
148 hash.update(await NodeFSP.readFile(NodePath.join(directory, file)));
149 }
150 return hash.digest("hex");
151}