@akanjs/devkit 3.1.1-rc.1 → 3.1.1-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanConfig/akanConfig.test.ts +13 -2
- package/akanConfig/akanConfig.ts +5 -5
- package/akanConfig/productionLockfile.test.ts +80 -0
- package/akanConfig/productionLockfile.ts +32 -0
- package/akanContext.ts +25 -0
- package/applicationBuildRunner.test.ts +41 -0
- package/applicationBuildRunner.ts +23 -3
- package/biomeBase.test.ts +2 -2
- package/cloud/cloudApi.ts +10 -4
- package/cloud/cloudHttpError.test.ts +8 -0
- package/cloud/cloudHttpError.ts +11 -1
- package/cloud/cloudUpdates.ts +48 -2
- package/cloud/updatePackUpload.test.ts +38 -2
- package/cloud/updatePackUpload.ts +5 -1
- package/commandDecorators/command.ts +3 -14
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
- package/commandDecorators/workspaceVersionCheck.ts +50 -0
- package/executors.test.ts +21 -1
- package/lint/gritRules.test.ts +3 -3
- package/linter.test.ts +18 -1
- package/linter.ts +10 -6
- package/mobile/desktopRelease.test.ts +473 -0
- package/mobile/desktopRelease.ts +307 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +29 -5
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +2 -2
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as nativeApi from "akanjs/native/api";
|
|
6
|
+
import { AkanAppConfig } from "../akanConfig";
|
|
7
|
+
import { tempDirs } from "../testHelpers";
|
|
8
|
+
import type { DesktopArch } from "./desktopBin";
|
|
9
|
+
import { DesktopRelease, type SignatureTool } from "./desktopRelease";
|
|
10
|
+
import type { DesktopPlatform } from "./mobileTarget";
|
|
11
|
+
|
|
12
|
+
const makeDir = tempDirs("akan-desktop-release-");
|
|
13
|
+
const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
|
|
15
|
+
const keyOf = () => {
|
|
16
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
17
|
+
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
|
|
18
|
+
const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
|
|
19
|
+
return {
|
|
20
|
+
publicKey: raw,
|
|
21
|
+
keyId: nativeApi.keyIdOf(raw),
|
|
22
|
+
sign: (bytes: string) => `${signature(bytes)}\n`,
|
|
23
|
+
seal: (bytes: string) =>
|
|
24
|
+
`${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const key = keyOf();
|
|
28
|
+
const spare = keyOf();
|
|
29
|
+
const appId = "com.akancad.app";
|
|
30
|
+
|
|
31
|
+
interface Published {
|
|
32
|
+
updatesDir: string;
|
|
33
|
+
dir: string;
|
|
34
|
+
tarSha: string;
|
|
35
|
+
delta: string;
|
|
36
|
+
build: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const publish = async ({
|
|
40
|
+
platform = "macos",
|
|
41
|
+
arch = "arm64",
|
|
42
|
+
channel = "main",
|
|
43
|
+
app = platform === "macos" ? "Akancad.app" : "akancad",
|
|
44
|
+
gz = "the release's gzip tar",
|
|
45
|
+
target = "default",
|
|
46
|
+
signer = key,
|
|
47
|
+
pair = "same",
|
|
48
|
+
}: {
|
|
49
|
+
platform?: DesktopPlatform;
|
|
50
|
+
arch?: DesktopArch;
|
|
51
|
+
channel?: string;
|
|
52
|
+
app?: string;
|
|
53
|
+
gz?: string;
|
|
54
|
+
target?: string;
|
|
55
|
+
signer?: ReturnType<typeof keyOf>;
|
|
56
|
+
pair?: "same" | "earlier" | "none";
|
|
57
|
+
} = {}) => {
|
|
58
|
+
const updatesDir = await makeDir();
|
|
59
|
+
const dir = path.join(updatesDir, `${platform}-${arch}`);
|
|
60
|
+
await mkdir(path.join(dir, "app"), { recursive: true });
|
|
61
|
+
const tarSha = sha256(`${channel} tar`);
|
|
62
|
+
const from = sha256("the previous tar");
|
|
63
|
+
const delta = `${from}-${tarSha}.delta.gz`;
|
|
64
|
+
await writeFile(path.join(dir, "app", `${tarSha}.tar.gz`), gz);
|
|
65
|
+
await writeFile(path.join(dir, "app", delta), "a delta");
|
|
66
|
+
const build = 1_761_699_000;
|
|
67
|
+
const manifest = {
|
|
68
|
+
schema: 1,
|
|
69
|
+
kind: "app",
|
|
70
|
+
app: appId,
|
|
71
|
+
platform,
|
|
72
|
+
channel,
|
|
73
|
+
sequence: 1_761_700_000,
|
|
74
|
+
bundle: "1.2.0-1761700000",
|
|
75
|
+
runtimeVersion: "0.12.0",
|
|
76
|
+
version: "1.2.0",
|
|
77
|
+
build,
|
|
78
|
+
arch,
|
|
79
|
+
files: [{ path: app, sha256: tarSha, size: 2048 }],
|
|
80
|
+
archive: { sha256: tarSha, url: `app/${tarSha}.tar.gz`, size: gz.length, gzSha256: sha256(gz) },
|
|
81
|
+
patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
|
|
82
|
+
server: true,
|
|
83
|
+
};
|
|
84
|
+
await mkdir(path.join(dir, channel), { recursive: true });
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(dir, channel, "app.json"),
|
|
87
|
+
signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
|
|
88
|
+
);
|
|
89
|
+
if (pair !== "none") {
|
|
90
|
+
const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
|
|
91
|
+
const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
|
|
92
|
+
await writeFile(path.join(dir, `${channel}.json`), bytes);
|
|
93
|
+
await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
|
|
94
|
+
}
|
|
95
|
+
return { updatesDir, dir, tarSha, delta, build } satisfies Published;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const read = (
|
|
99
|
+
{ updatesDir }: Published,
|
|
100
|
+
{
|
|
101
|
+
platform = "macos",
|
|
102
|
+
arch = "arm64",
|
|
103
|
+
channel = "main",
|
|
104
|
+
publicKeys = [key.publicKey, spare.publicKey],
|
|
105
|
+
revokedKeys = [],
|
|
106
|
+
target = "default",
|
|
107
|
+
id = appId,
|
|
108
|
+
}: {
|
|
109
|
+
platform?: DesktopPlatform;
|
|
110
|
+
arch?: DesktopArch;
|
|
111
|
+
channel?: string;
|
|
112
|
+
publicKeys?: string[];
|
|
113
|
+
revokedKeys?: string[];
|
|
114
|
+
target?: string;
|
|
115
|
+
id?: string;
|
|
116
|
+
} = {},
|
|
117
|
+
) =>
|
|
118
|
+
DesktopRelease.read(updatesDir, {
|
|
119
|
+
platform,
|
|
120
|
+
arch,
|
|
121
|
+
channel,
|
|
122
|
+
appId: id,
|
|
123
|
+
target,
|
|
124
|
+
publicKeys,
|
|
125
|
+
revokedKeys,
|
|
126
|
+
api: nativeApi,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const entriesOf = (archive: string) =>
|
|
130
|
+
Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
|
|
131
|
+
|
|
132
|
+
//? Every header, a pax one too: a file's name is the `path` of the pax header before it, else its own name field.
|
|
133
|
+
const headersOf = (bytes: Uint8Array) => {
|
|
134
|
+
const headers: { type: string; name: string; prefix: string; paxKeys: string[] }[] = [];
|
|
135
|
+
let paxPath: string | null = null;
|
|
136
|
+
for (let at = 0; at + 512 <= bytes.length; ) {
|
|
137
|
+
const block = Buffer.from(bytes.subarray(at, at + 512));
|
|
138
|
+
if (block.every((byte) => byte === 0)) break;
|
|
139
|
+
const field = (from: number, width: number) => block.toString("latin1", from, from + width).replace(/\0.*$/s, "");
|
|
140
|
+
const type = field(156, 1);
|
|
141
|
+
const size = Number.parseInt(field(124, 12), 8);
|
|
142
|
+
const records = Buffer.from(bytes.subarray(at + 512, at + 512 + size))
|
|
143
|
+
.toString("utf8")
|
|
144
|
+
.split("\n")
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
147
|
+
const pax = type === "x" ? records.map((record) => record.slice(record.indexOf(" ") + 1)) : [];
|
|
148
|
+
const paxKeys = pax.map((record) => record.slice(0, record.indexOf("=")));
|
|
149
|
+
headers.push({ type, name: (type === "x" ? null : paxPath) ?? field(0, 100), prefix: field(345, 155), paxKeys });
|
|
150
|
+
paxPath = type === "x" ? (pax.find((record) => record.startsWith("path="))?.slice(5) ?? null) : null;
|
|
151
|
+
}
|
|
152
|
+
return headers;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const extract = async (archive: string) => {
|
|
156
|
+
const out = await makeDir();
|
|
157
|
+
expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
describe("DesktopRelease", () => {
|
|
162
|
+
test("packs only what the channel's manifest names: no other channel, earlier archive or folder record", async () => {
|
|
163
|
+
const published = await publish();
|
|
164
|
+
const { dir, tarSha, delta } = published;
|
|
165
|
+
await publish({ channel: "develop" }).then(async (other) => {
|
|
166
|
+
await mkdir(path.join(dir, "develop"), { recursive: true });
|
|
167
|
+
for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
|
|
168
|
+
await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
|
|
169
|
+
});
|
|
170
|
+
await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
|
|
171
|
+
await writeFile(path.join(dir, "mirror.txt"), "not part of the release");
|
|
172
|
+
const release = await read(published);
|
|
173
|
+
|
|
174
|
+
const entries = await release.withArchive(release.record(null), async (archive) => entriesOf(archive));
|
|
175
|
+
|
|
176
|
+
expect(entries).toEqual([
|
|
177
|
+
"./release.json",
|
|
178
|
+
"./main/app.json",
|
|
179
|
+
"./main.json",
|
|
180
|
+
"./main.json.sig",
|
|
181
|
+
`./app/${tarSha}.tar.gz`,
|
|
182
|
+
`./app/${delta}`,
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("keeps the one tar shape Akan Cloud accepts, a pax name of a long patch included", async () => {
|
|
187
|
+
const published = await publish();
|
|
188
|
+
const release = await read(published);
|
|
189
|
+
|
|
190
|
+
const headers = await release.withArchive(release.record(null), async (archive) =>
|
|
191
|
+
headersOf(await readFile(archive)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(new Set(headers.map(({ type }) => type))).toEqual(new Set(["0", "x"]));
|
|
195
|
+
expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
|
|
196
|
+
expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
|
|
197
|
+
const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
|
|
198
|
+
expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
|
|
199
|
+
expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
|
|
200
|
+
expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
|
|
201
|
+
expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
|
|
205
|
+
const published = await publish();
|
|
206
|
+
const release = await read(published);
|
|
207
|
+
const record = release.record({ teamId: "ABCDE12345" });
|
|
208
|
+
|
|
209
|
+
const out = await release.withArchive(record, extract);
|
|
210
|
+
|
|
211
|
+
for (const name of ["main/app.json", "main.json", "main.json.sig"])
|
|
212
|
+
expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
|
|
213
|
+
expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
|
|
214
|
+
expect(record).toEqual({
|
|
215
|
+
schema: 2,
|
|
216
|
+
cli: AkanAppConfig.akanVersion,
|
|
217
|
+
platform: "macos",
|
|
218
|
+
arch: "arm64",
|
|
219
|
+
channel: "main",
|
|
220
|
+
signer: { teamId: "ABCDE12345" },
|
|
221
|
+
builtAt: new Date(published.build * 1000).toISOString(),
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("leaves no archive behind, also when the upload fails", async () => {
|
|
226
|
+
const release = await read(await publish());
|
|
227
|
+
let kept = "";
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
release.withArchive(release.record(null), async (archive) => {
|
|
231
|
+
kept = archive;
|
|
232
|
+
throw new Error("upload failed");
|
|
233
|
+
}),
|
|
234
|
+
).rejects.toThrow("upload failed");
|
|
235
|
+
expect(await Bun.file(kept).exists()).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
|
|
239
|
+
const published = await publish();
|
|
240
|
+
const envelope = path.join(published.dir, "main", "app.json");
|
|
241
|
+
const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
|
|
242
|
+
|
|
243
|
+
await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
|
|
244
|
+
await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
|
|
248
|
+
const entriesFor = async (published: Published) => {
|
|
249
|
+
const release = await read(published);
|
|
250
|
+
return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
|
|
251
|
+
};
|
|
252
|
+
const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
|
|
253
|
+
|
|
254
|
+
const bySpare = await publish({ signer: spare, pair: "earlier" });
|
|
255
|
+
expect(await entriesFor(bySpare)).toEqual([
|
|
256
|
+
"./release.json",
|
|
257
|
+
"./main/app.json",
|
|
258
|
+
tail(bySpare),
|
|
259
|
+
`./app/${bySpare.delta}`,
|
|
260
|
+
]);
|
|
261
|
+
const unpaired = await publish({ pair: "none" });
|
|
262
|
+
expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
|
|
263
|
+
const forged = await publish();
|
|
264
|
+
await writeFile(
|
|
265
|
+
path.join(forged.dir, "main.json.sig"),
|
|
266
|
+
spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
|
|
267
|
+
);
|
|
268
|
+
expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
|
|
272
|
+
const published = await publish();
|
|
273
|
+
|
|
274
|
+
await expect(read(published, { id: "com.other" })).rejects.toThrow(
|
|
275
|
+
"is not a release of com.other target default for macos-arm64 on channel main.",
|
|
276
|
+
);
|
|
277
|
+
await expect(read(published, { target: "pilot" })).rejects.toThrow(
|
|
278
|
+
`is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
|
|
279
|
+
);
|
|
280
|
+
await expect(read(published, { channel: "develop" })).rejects.toThrow(
|
|
281
|
+
`${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
|
|
282
|
+
);
|
|
283
|
+
await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("refuses an archive that is not the file the manifest names, by its length or its digest", async () => {
|
|
287
|
+
const published = await publish();
|
|
288
|
+
const archive = path.join(published.dir, "app", `${published.tarSha}.tar.gz`);
|
|
289
|
+
const release = await read(published);
|
|
290
|
+
const send = () => release.withArchive(release.record(null), async () => "sent");
|
|
291
|
+
|
|
292
|
+
await writeFile(archive, "the release's gzip tar!");
|
|
293
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
|
|
294
|
+
await writeFile(archive, "the release's gzip taR");
|
|
295
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const recordingTool = (
|
|
300
|
+
answer: (cmd: string[], env: Record<string, string>) => Promise<{ code: number; output: string }>,
|
|
301
|
+
) => {
|
|
302
|
+
const calls: { cmd: string[]; env: Record<string, string> }[] = [];
|
|
303
|
+
const run: SignatureTool = async (cmd, { env = {} } = {}) => {
|
|
304
|
+
calls.push({ cmd, env });
|
|
305
|
+
return await answer(cmd, env);
|
|
306
|
+
};
|
|
307
|
+
return { calls, run };
|
|
308
|
+
};
|
|
309
|
+
const bom = String.fromCharCode(0xfeff);
|
|
310
|
+
const extractedTo = (cmd: string[]) => cmd[cmd.indexOf("-C") + 1] ?? "";
|
|
311
|
+
|
|
312
|
+
describe("DesktopRelease signer", () => {
|
|
313
|
+
const macTool = (codesign: { code: number; output: string }, { plist = true } = {}) =>
|
|
314
|
+
recordingTool(async (cmd) => {
|
|
315
|
+
if (cmd[0] === "/usr/bin/tar") {
|
|
316
|
+
if (plist) await Bun.write(path.join(extractedTo(cmd), "Akancad.app/Contents/Info.plist"), "<plist/>");
|
|
317
|
+
return { code: 1, output: "tar: _CodeSignature/*: Not found in archive" };
|
|
318
|
+
}
|
|
319
|
+
return codesign;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("macOS: the TeamIdentifier codesign shows for the app in the archive", async () => {
|
|
323
|
+
const release = await read(await publish());
|
|
324
|
+
const { calls, run } = macTool({
|
|
325
|
+
code: 0,
|
|
326
|
+
output: "Executable=…\nTeamIdentifier=ABCDE12345\nSealed Resources=none\n",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({
|
|
330
|
+
signer: { teamId: "ABCDE12345" },
|
|
331
|
+
unread: null,
|
|
332
|
+
});
|
|
333
|
+
const [extraction, display] = calls;
|
|
334
|
+
expect(extraction?.cmd.slice(0, 3)).toEqual([
|
|
335
|
+
"/usr/bin/tar",
|
|
336
|
+
"-xzf",
|
|
337
|
+
path.join(release.dir, release.manifest.archive.url),
|
|
338
|
+
]);
|
|
339
|
+
expect(extraction?.cmd.filter((_, idx, cmd) => cmd[idx - 1] === "--include")).toEqual([
|
|
340
|
+
"Akancad.app/Contents/Info.plist",
|
|
341
|
+
"Akancad.app/Contents/MacOS/*",
|
|
342
|
+
"Akancad.app/Contents/_CodeSignature/*",
|
|
343
|
+
]);
|
|
344
|
+
expect(display?.cmd).toEqual([
|
|
345
|
+
"/usr/bin/codesign",
|
|
346
|
+
"--display",
|
|
347
|
+
"--verbose=2",
|
|
348
|
+
path.join(extractedTo(extraction?.cmd ?? []), "Akancad.app"),
|
|
349
|
+
]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("macOS: an app signed ad hoc or not at all has no signer, and nothing to warn about", async () => {
|
|
353
|
+
const release = await read(await publish());
|
|
354
|
+
const adHoc = macTool({ code: 0, output: "Signature=adhoc\nTeamIdentifier=not set\n" });
|
|
355
|
+
const unsigned = macTool({ code: 1, output: "/tmp/x/Akancad.app: code object is not signed at all\n" });
|
|
356
|
+
|
|
357
|
+
for (const { run } of [adHoc, unsigned])
|
|
358
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({ signer: null, unread: null });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("macOS: says why when codesign or the archive cannot tell", async () => {
|
|
362
|
+
const release = await read(await publish());
|
|
363
|
+
|
|
364
|
+
expect(
|
|
365
|
+
await release.signer({ exeName: "akancad", host: "darwin", run: macTool({ code: 1, output: "boom\n" }).run }),
|
|
366
|
+
).toEqual({ signer: null, unread: "codesign could not read Akancad.app: boom" });
|
|
367
|
+
expect(
|
|
368
|
+
await release.signer({
|
|
369
|
+
exeName: "akancad",
|
|
370
|
+
host: "darwin",
|
|
371
|
+
run: macTool({ code: 0, output: "" }, { plist: false }).run,
|
|
372
|
+
}),
|
|
373
|
+
).toEqual({ signer: null, unread: "the archive holds no Akancad.app/Contents/Info.plist" });
|
|
374
|
+
const { calls, run } = macTool({ code: 0, output: "TeamIdentifier=ABCDE12345\n" });
|
|
375
|
+
expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({
|
|
376
|
+
signer: null,
|
|
377
|
+
unread: "codesign reads a macOS app's signer on macOS only",
|
|
378
|
+
});
|
|
379
|
+
expect(calls).toEqual([]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const windowsTool = (powershell: (out: string) => Promise<number>) =>
|
|
383
|
+
recordingTool(async (cmd, env) => {
|
|
384
|
+
if (cmd[0] === "tar.exe") {
|
|
385
|
+
await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
|
|
386
|
+
return { code: 0, output: "" };
|
|
387
|
+
}
|
|
388
|
+
return { code: await powershell(env.AKAN_SIGNER_OUT), output: "" };
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("Windows: the Authenticode subject of the app's executable, read as UTF-8", async () => {
|
|
392
|
+
const release = await read(await publish({ platform: "windows", arch: "x64" }), {
|
|
393
|
+
platform: "windows",
|
|
394
|
+
arch: "x64",
|
|
395
|
+
});
|
|
396
|
+
const subject = "CN=아칸 주식회사, O=아칸 주식회사, C=KR";
|
|
397
|
+
const { calls, run } = windowsTool(async (out) => {
|
|
398
|
+
await Bun.write(out, `${bom}${subject}\r\n`);
|
|
399
|
+
return 0;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run })).toEqual({
|
|
403
|
+
signer: { subject },
|
|
404
|
+
unread: null,
|
|
405
|
+
});
|
|
406
|
+
const [extraction, check] = calls;
|
|
407
|
+
expect(extraction?.cmd.at(-1)).toBe("akancad/akancad.exe");
|
|
408
|
+
expect(check?.env.AKAN_RELEASE_EXE).toBe(path.join(extractedTo(extraction?.cmd ?? []), "akancad/akancad.exe"));
|
|
409
|
+
expect(check?.cmd.slice(1, 4)).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand"]);
|
|
410
|
+
expect(Buffer.from(check?.cmd[4] ?? "", "base64").toString("utf16le")).toContain(
|
|
411
|
+
"Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("Windows: an executable without a valid signature has no signer; a PowerShell that fails says so", async () => {
|
|
416
|
+
const release = await read(await publish({ platform: "windows", arch: "x64" }), {
|
|
417
|
+
platform: "windows",
|
|
418
|
+
arch: "x64",
|
|
419
|
+
});
|
|
420
|
+
const unsigned = windowsTool(async (out) => {
|
|
421
|
+
await Bun.write(out, `${bom}\r\n`);
|
|
422
|
+
return 0;
|
|
423
|
+
});
|
|
424
|
+
const blocked = windowsTool(async () => 1);
|
|
425
|
+
|
|
426
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run: unsigned.run })).toEqual({
|
|
427
|
+
signer: null,
|
|
428
|
+
unread: null,
|
|
429
|
+
});
|
|
430
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run: blocked.run })).toEqual({
|
|
431
|
+
signer: null,
|
|
432
|
+
unread: "PowerShell could not read the Authenticode signature of akancad/akancad.exe",
|
|
433
|
+
});
|
|
434
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run: unsigned.run })).toEqual({
|
|
435
|
+
signer: null,
|
|
436
|
+
unread: "PowerShell reads a Windows app's Authenticode signer on Windows only",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("Linux: no signer, and nothing run", async () => {
|
|
441
|
+
const release = await read(await publish({ platform: "linux", arch: "x64" }), { platform: "linux", arch: "x64" });
|
|
442
|
+
const { calls, run } = recordingTool(async () => ({ code: 0, output: "" }));
|
|
443
|
+
|
|
444
|
+
expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({ signer: null, unread: null });
|
|
445
|
+
expect(calls).toEqual([]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test.skipIf(process.platform !== "darwin")(
|
|
449
|
+
"macOS: reads an ad hoc signature from the archive itself with the system tar and codesign",
|
|
450
|
+
async () => {
|
|
451
|
+
const build = await makeDir();
|
|
452
|
+
const contents = path.join(build, "Akancad.app", "Contents");
|
|
453
|
+
await mkdir(path.join(contents, "MacOS"), { recursive: true });
|
|
454
|
+
await mkdir(path.join(contents, "Resources"), { recursive: true });
|
|
455
|
+
await writeFile(path.join(contents, "Resources", "server.json"), "{}");
|
|
456
|
+
await writeFile(
|
|
457
|
+
path.join(contents, "Info.plist"),
|
|
458
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict><key>CFBundleExecutable</key><string>akancad</string><key>CFBundleIdentifier</key><string>com.akancad.app</string></dict></plist>\n',
|
|
459
|
+
);
|
|
460
|
+
await writeFile(path.join(contents, "MacOS", "akancad"), await readFile("/usr/bin/true"));
|
|
461
|
+
const signed = Bun.spawnSync(["/usr/bin/codesign", "--force", "--sign", "-", path.join(build, "Akancad.app")]);
|
|
462
|
+
expect(signed.exitCode).toBe(0);
|
|
463
|
+
const gz = path.join(build, "release.tar.gz");
|
|
464
|
+
const tarred = Bun.spawnSync(["/usr/bin/tar", "--no-mac-metadata", "-czf", gz, "-C", build, "Akancad.app"]);
|
|
465
|
+
expect(tarred.exitCode).toBe(0);
|
|
466
|
+
const published = await publish();
|
|
467
|
+
await writeFile(path.join(published.dir, "app", `${published.tarSha}.tar.gz`), await readFile(gz));
|
|
468
|
+
const release = await read(published);
|
|
469
|
+
|
|
470
|
+
expect(await release.signer({ exeName: "akancad" })).toEqual({ signer: null, unread: null });
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
});
|