@akanjs/devkit 3.1.1-rc.6 → 3.1.1-rc.8

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/cloud/cloudApi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isRecord } from "akanjs/common";
1
+ import { isRecord, Logger } from "akanjs/common";
2
2
  import type { Workspace } from "../commandDecorators";
3
3
  import { CloudHttpError } from "./cloudHttpError";
4
4
  import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
@@ -86,7 +86,10 @@ export interface CloudRecord {
86
86
  export interface UpdateUploadRequest {
87
87
  appName: string;
88
88
  target: string;
89
- platform: "ios" | "android";
89
+ platform: "ios" | "android" | "macos" | "windows" | "linux";
90
+ //? Absent is a phone's web bundle, so a phone request reads as it did before desktop releases.
91
+ kind?: "app";
92
+ arch?: "arm64" | "x64";
90
93
  channel: string;
91
94
  notes: string | null;
92
95
  size: number;
@@ -120,8 +123,11 @@ export class CloudApi {
120
123
  if (error.status === 401)
121
124
  return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
122
125
  if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
123
- if (error.status === 400)
124
- return new Error(`The project refused the request${error.error ? `: ${error.error}` : ""}.`, { cause: error });
126
+ if (error.status === 400) {
127
+ const { data } = error;
128
+ const why = `${error.error ? `: ${error.error}` : ""}${data ? ` ${Logger.inlineData(data)}` : ""}`;
129
+ return new Error(`The project refused the request${why}.`, { cause: error });
130
+ }
125
131
  //? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
126
132
  const { errKey } = error;
127
133
  if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
@@ -20,6 +20,14 @@ describe("CloudHttpError", () => {
20
20
  expect(plain.errKey).toBeNull();
21
21
  });
22
22
 
23
+ test("reads the data a server Err carried, and none from a body without it", () => {
24
+ const body = JSON.stringify({ error: "release.error.desktopMalformed", data: { reason: "a directory entry" } });
25
+
26
+ expect(new CloudHttpError(400, "Bad Request", "/x", body).data).toEqual({ reason: "a directory entry" });
27
+ expect(new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request","data":"x"}').data).toBeNull();
28
+ expect(new CloudHttpError(502, "Bad Gateway", "/x", "<html>502</html>").data).toBeNull();
29
+ });
30
+
23
31
  test("takes Retry-After as seconds or as a date, and nothing else", () => {
24
32
  expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
25
33
  expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
@@ -26,9 +26,19 @@ export class CloudHttpError extends Error {
26
26
 
27
27
  /** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
28
28
  get error(): string | null {
29
+ const error = this.#payload?.error;
30
+ return typeof error === "string" ? error : null;
31
+ }
32
+
33
+ get data(): Record<string, unknown> | null {
34
+ const data = this.#payload?.data;
35
+ return isRecord(data) ? data : null;
36
+ }
37
+
38
+ get #payload(): Record<string, unknown> | null {
29
39
  try {
30
40
  const parsed: unknown = JSON.parse(this.body);
31
- return isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : null;
41
+ return isRecord(parsed) ? parsed : null;
32
42
  } catch {
33
43
  return null;
34
44
  }
@@ -3,15 +3,18 @@ import { Logger } from "akanjs/common";
3
3
  import type { NativeEnv } from "../akanConfig";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { ExitCodeError } from "../commandDecorators/exitCodeError";
6
+ import type { DesktopRelease, DesktopSigner } from "../mobile/desktopRelease";
6
7
  import { type MobilePlatform, resolveMobileTargets } from "../mobile/mobileTarget";
7
8
  import { NativeApp } from "../mobile/nativeApp";
8
9
  import { NativePublicEnv } from "../mobile/nativePublicEnv";
9
10
  import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
10
11
  import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
12
+ import { CloudHttpError } from "./cloudHttpError";
11
13
  import { UpdatePackUpload } from "./updatePackUpload";
12
14
 
13
15
  //* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
14
- //* it to sign and publish, and the store build a pack of the same commit is embedded in.
16
+ //* it to sign and publish, and the store build a pack of the same commit is embedded in. A desktop release is signed
17
+ //* where it was published, and goes up as it is.
15
18
  export class CloudUpdates {
16
19
  constructor(
17
20
  readonly cloudApi: CloudApi,
@@ -59,7 +62,7 @@ export class CloudUpdates {
59
62
  const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
60
63
  .send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
61
64
  .catch((error: unknown) => {
62
- throw CloudApi.refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
65
+ throw CloudUpdates.#refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
63
66
  });
64
67
  const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
65
68
  if (release.status === "rejected")
@@ -77,6 +80,48 @@ export class CloudUpdates {
77
80
  });
78
81
  }
79
82
 
83
+ //? `manifestUrl` is where the project serves the channel once published; before that, the URL the app was built with.
84
+ async pushRelease(
85
+ release: DesktopRelease,
86
+ {
87
+ appName,
88
+ target,
89
+ notes,
90
+ signer,
91
+ url,
92
+ }: { appName: string; target: string; notes: string | null; signer: DesktopSigner; url: string },
93
+ ) {
94
+ const { platform, arch, channel } = release;
95
+ return await release.withArchive(release.record(signer), async (archive) => {
96
+ const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
97
+ .send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
98
+ .catch((error: unknown) => {
99
+ throw CloudUpdates.#refusal(error, "a release is uploaded by a project member signed in with `akan login`");
100
+ });
101
+ const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
102
+ const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
103
+ if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
104
+ const manifestUrl =
105
+ typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
106
+ const what = `${label} (${release.manifest.bundle}, ${release.payloads.length + 3} files)`;
107
+ Logger.rawLog(
108
+ record.status === "published"
109
+ ? `Published ${what}. Installed apps read ${manifestUrl}.`
110
+ : `Staged ${what}. Publish it from the project's release screen; installed apps read ${manifestUrl}.`,
111
+ );
112
+ if (problems) Logger.rawLog(`The project noted:\n${problems}`);
113
+ return record;
114
+ });
115
+ }
116
+
117
+ //? A 400 naming a release Err key is the project's verdict on the release, as a rejected one is: a retry changes nothing.
118
+ static #refusal(error: unknown, who: string) {
119
+ const refusal = CloudApi.refusal(error, who);
120
+ const verdict =
121
+ error instanceof CloudHttpError && error.status === 400 && !!error.errKey?.startsWith("release.error.");
122
+ return verdict && refusal instanceof Error ? new ExitCodeError(refusal.message, 2) : refusal;
123
+ }
124
+
80
125
  //? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
81
126
  async registerBinary(
82
127
  app: App,
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
- import { writeFile } from "node:fs/promises";
3
+ import { truncate, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import type { Workspace } from "../commandDecorators";
6
6
  import { tempDirs } from "../testHelpers";
@@ -24,6 +24,7 @@ class FakeCloudHost {
24
24
  commitAnswers: (number | [number, string] | Response)[] = [];
25
25
  release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
26
26
  requestRefusal: Response | null = null;
27
+ contentType = "application/gzip";
27
28
 
28
29
  constructor() {
29
30
  this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
@@ -42,6 +43,7 @@ class FakeCloudHost {
42
43
  this.stallPuts = false;
43
44
  this.commitAnswers = [];
44
45
  this.requestRefusal = null;
46
+ this.contentType = "application/gzip";
45
47
  }
46
48
 
47
49
  async #route(request: Request) {
@@ -54,7 +56,7 @@ class FakeCloudHost {
54
56
  uploadId,
55
57
  uploadUrl: `${this.host}/storage/${uploadId}?X-Amz-Signature=secret-signature`,
56
58
  expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
57
- contentType: "application/gzip",
59
+ contentType: this.contentType,
58
60
  });
59
61
  }
60
62
  if (url.pathname.startsWith("/storage/") && request.method === "PUT") {
@@ -302,4 +304,38 @@ describe("UpdatePackUpload", () => {
302
304
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
303
305
  expect(cloudHost.requests).toEqual([]);
304
306
  });
307
+
308
+ test("announces a desktop release with its kind and CPU, a phone pack with neither, and PUTs the ticket's type", async () => {
309
+ const desktop = { ...fields, platform: "macos" as const, kind: "app" as const, arch: "arm64" as const };
310
+ cloudHost.contentType = "application/x-tar";
311
+
312
+ await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("release tar"), desktop);
313
+ await new UpdatePackUpload(api, "project-1", quick).send(await archiveOf("pack bytes"), fields);
314
+
315
+ expect(cloudHost.requests[0]).toMatchObject({ platform: "macos", kind: "app", arch: "arm64", size: 11 });
316
+ expect(Object.keys(cloudHost.requests[1] ?? {}).filter((key) => key === "kind" || key === "arch")).toEqual([]);
317
+ expect(cloudHost.puts.map((put) => put.headers["content-type"])).toEqual([
318
+ "application/x-tar",
319
+ "application/x-tar",
320
+ ]);
321
+ });
322
+
323
+ test("takes a desktop release up to 1 GiB and a phone pack up to 200 MiB, refusing more before a ticket", async () => {
324
+ const desktop = { ...fields, platform: "windows" as const, kind: "app" as const, arch: "x64" as const };
325
+ const sparse = async (bytes: number) => {
326
+ const archive = await archiveOf("");
327
+ await truncate(archive, bytes);
328
+ return archive;
329
+ };
330
+ const upload = new UpdatePackUpload(api, "project-1", quick);
331
+
332
+ await expect(upload.send(await sparse(UpdatePackUpload.maxBytes + 1), fields)).rejects.toThrow(
333
+ `The pack archive is ${UpdatePackUpload.maxBytes + 1} bytes; a project takes 1 byte to 200 MiB.`,
334
+ );
335
+ await expect(upload.send(await sparse(UpdatePackUpload.maxAppBytes + 1), desktop)).rejects.toThrow(
336
+ `The release archive is ${UpdatePackUpload.maxAppBytes + 1} bytes; a project takes 1 byte to 1 GiB.`,
337
+ );
338
+ expect(cloudHost.requests).toEqual([]);
339
+ expect(UpdatePackUpload.maxAppBytes).toBe(1_073_741_824);
340
+ });
305
341
  });
@@ -16,6 +16,7 @@ interface UpdatePackUploadOptions {
16
16
  //* embedded pack; either re-hashes it, and an upload is used once, one way.
17
17
  export class UpdatePackUpload {
18
18
  static readonly maxBytes = 200 * 1024 * 1024;
19
+ static readonly maxAppBytes = 1024 * 1024 * 1024;
19
20
  static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
20
21
  //? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
21
22
  static readonly #putFloorBytesPerSecond = 64 * 1024;
@@ -57,7 +58,10 @@ export class UpdatePackUpload {
57
58
  /** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
58
59
  async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
59
60
  const file = Bun.file(archive);
60
- if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
61
+ if (fields.kind === "app") {
62
+ if (file.size < 1 || file.size > UpdatePackUpload.maxAppBytes)
63
+ throw new Error(`The release archive is ${file.size} bytes; a project takes 1 byte to 1 GiB.`);
64
+ } else if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
61
65
  throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
62
66
  const hasher = new Bun.CryptoHasher("sha256");
63
67
  for await (const chunk of file.stream()) hasher.update(chunk);
@@ -0,0 +1,402 @@
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 { AkanAppConfig } from "../akanConfig";
6
+ import { tempDirs } from "../testHelpers";
7
+ import type { DesktopArch } from "./desktopBin";
8
+ import { DesktopRelease, type SignatureTool } from "./desktopRelease";
9
+ import type { DesktopPlatform } from "./mobileTarget";
10
+
11
+ const makeDir = tempDirs("akan-desktop-release-");
12
+ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
13
+
14
+ const keyOf = () => {
15
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
16
+ return {
17
+ publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"),
18
+ sign: (bytes: string) => `${sign(null, Buffer.from(bytes), privateKey).toString("base64")}\n`,
19
+ };
20
+ };
21
+ const key = keyOf();
22
+ const appId = "com.akancad.app";
23
+
24
+ interface Published {
25
+ updatesDir: string;
26
+ dir: string;
27
+ tarSha: string;
28
+ delta: string;
29
+ build: number;
30
+ }
31
+
32
+ const publish = async ({
33
+ platform = "macos",
34
+ arch = "arm64",
35
+ channel = "main",
36
+ app = platform === "macos" ? "Akancad.app" : "akancad",
37
+ gz = "the release's gzip tar",
38
+ }: {
39
+ platform?: DesktopPlatform;
40
+ arch?: DesktopArch;
41
+ channel?: string;
42
+ app?: string;
43
+ gz?: string;
44
+ } = {}) => {
45
+ const updatesDir = await makeDir();
46
+ const dir = path.join(updatesDir, `${platform}-${arch}`);
47
+ await mkdir(path.join(dir, "app"), { recursive: true });
48
+ const tarSha = sha256(`${channel} tar`);
49
+ const from = sha256("the previous tar");
50
+ const delta = `${from}-${tarSha}.delta.gz`;
51
+ await writeFile(path.join(dir, "app", `${tarSha}.tar.gz`), gz);
52
+ await writeFile(path.join(dir, "app", delta), "a delta");
53
+ const build = 1_761_699_000;
54
+ const manifest = {
55
+ schema: 1,
56
+ kind: "app",
57
+ app: appId,
58
+ platform,
59
+ channel,
60
+ sequence: 1_761_700_000,
61
+ bundle: "1.2.0-1761700000",
62
+ runtimeVersion: "0.12.0",
63
+ version: "1.2.0",
64
+ build,
65
+ arch,
66
+ files: [{ path: app, sha256: tarSha, size: 2048 }],
67
+ archive: { sha256: tarSha, url: `app/${tarSha}.tar.gz`, size: gz.length, gzSha256: sha256(gz) },
68
+ patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
69
+ server: true,
70
+ };
71
+ const bytes = `${JSON.stringify(manifest, null, 2)}\n`;
72
+ await writeFile(path.join(dir, `${channel}.json`), bytes);
73
+ await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
74
+ return { updatesDir, dir, tarSha, delta, build } satisfies Published;
75
+ };
76
+
77
+ const read = (
78
+ { updatesDir }: Published,
79
+ {
80
+ platform = "macos",
81
+ arch = "arm64",
82
+ channel = "main",
83
+ publicKey = key.publicKey,
84
+ id = appId,
85
+ }: { platform?: DesktopPlatform; arch?: DesktopArch; channel?: string; publicKey?: string; id?: string } = {},
86
+ ) => DesktopRelease.read(updatesDir, { platform, arch, channel, appId: id, publicKey });
87
+
88
+ const entriesOf = (archive: string) =>
89
+ Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
90
+
91
+ //? Every header, a pax one too: a file's name is the `path` of the pax header before it, else its own name field.
92
+ const headersOf = (bytes: Uint8Array) => {
93
+ const headers: { type: string; name: string; prefix: string; paxKeys: string[] }[] = [];
94
+ let paxPath: string | null = null;
95
+ for (let at = 0; at + 512 <= bytes.length; ) {
96
+ const block = Buffer.from(bytes.subarray(at, at + 512));
97
+ if (block.every((byte) => byte === 0)) break;
98
+ const field = (from: number, width: number) => block.toString("latin1", from, from + width).replace(/\0.*$/s, "");
99
+ const type = field(156, 1);
100
+ const size = Number.parseInt(field(124, 12), 8);
101
+ const records = Buffer.from(bytes.subarray(at + 512, at + 512 + size))
102
+ .toString("utf8")
103
+ .split("\n")
104
+ .filter(Boolean);
105
+ at += 512 + Math.ceil(size / 512) * 512;
106
+ const pax = type === "x" ? records.map((record) => record.slice(record.indexOf(" ") + 1)) : [];
107
+ const paxKeys = pax.map((record) => record.slice(0, record.indexOf("=")));
108
+ headers.push({ type, name: (type === "x" ? null : paxPath) ?? field(0, 100), prefix: field(345, 155), paxKeys });
109
+ paxPath = type === "x" ? (pax.find((record) => record.startsWith("path="))?.slice(5) ?? null) : null;
110
+ }
111
+ return headers;
112
+ };
113
+
114
+ const extract = async (archive: string) => {
115
+ const out = await makeDir();
116
+ expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
117
+ return out;
118
+ };
119
+
120
+ describe("DesktopRelease", () => {
121
+ test("packs only what the channel's manifest names: no other channel, earlier archive or folder record", async () => {
122
+ const published = await publish();
123
+ const { dir, tarSha, delta } = published;
124
+ await publish({ channel: "develop" }).then(async (other) => {
125
+ for (const name of ["develop.json", "develop.json.sig"])
126
+ await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
127
+ });
128
+ await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
129
+ await writeFile(path.join(dir, "mirror.txt"), "not part of the release");
130
+ const release = await read(published);
131
+
132
+ const entries = await release.withArchive(release.record(null), async (archive) => entriesOf(archive));
133
+
134
+ expect(entries).toEqual([
135
+ "./release.json",
136
+ "./main.json",
137
+ "./main.json.sig",
138
+ `./app/${tarSha}.tar.gz`,
139
+ `./app/${delta}`,
140
+ ]);
141
+ });
142
+
143
+ test("keeps the one tar shape Akan Cloud accepts, a pax name of a long patch included", async () => {
144
+ const published = await publish();
145
+ const release = await read(published);
146
+
147
+ const headers = await release.withArchive(release.record(null), async (archive) =>
148
+ headersOf(await readFile(archive)),
149
+ );
150
+
151
+ expect(new Set(headers.map(({ type }) => type))).toEqual(new Set(["0", "x"]));
152
+ expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
153
+ expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
154
+ const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
155
+ expect(files.slice(0, 3)).toEqual(["./release.json", "./main.json", "./main.json.sig"]);
156
+ expect(files.slice(3).every((name) => name.startsWith("./app/"))).toBe(true);
157
+ expect(files.slice(3)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
158
+ expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
159
+ });
160
+
161
+ test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
162
+ const published = await publish();
163
+ const release = await read(published);
164
+ const record = release.record({ teamId: "ABCDE12345" });
165
+
166
+ const out = await release.withArchive(record, extract);
167
+
168
+ for (const name of ["main.json", "main.json.sig"])
169
+ expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
170
+ expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
171
+ expect(record).toEqual({
172
+ schema: 1,
173
+ cli: AkanAppConfig.akanVersion,
174
+ platform: "macos",
175
+ arch: "arm64",
176
+ channel: "main",
177
+ signer: { teamId: "ABCDE12345" },
178
+ builtAt: new Date(published.build * 1000).toISOString(),
179
+ });
180
+ });
181
+
182
+ test("leaves no archive behind, also when the upload fails", async () => {
183
+ const release = await read(await publish());
184
+ let kept = "";
185
+
186
+ await expect(
187
+ release.withArchive(release.record(null), async (archive) => {
188
+ kept = archive;
189
+ throw new Error("upload failed");
190
+ }),
191
+ ).rejects.toThrow("upload failed");
192
+ expect(await Bun.file(kept).exists()).toBe(false);
193
+ });
194
+
195
+ test("refuses a manifest the target's first key did not sign, before reading anything it names", async () => {
196
+ const published = await publish();
197
+
198
+ await expect(read(published, { publicKey: keyOf().publicKey })).rejects.toThrow(
199
+ "main.json.sig does not verify with updates.publicKeys[0], the one key installed apps trust",
200
+ );
201
+ });
202
+
203
+ test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
204
+ const published = await publish();
205
+
206
+ await expect(read(published, { id: "com.other" })).rejects.toThrow(
207
+ "is not a release of com.other for macos-arm64 on channel main.",
208
+ );
209
+ await expect(read(published, { channel: "develop" })).rejects.toThrow(
210
+ `${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
211
+ );
212
+ await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
213
+ });
214
+
215
+ test("refuses an archive that is not the file the manifest names, by its length or its digest", async () => {
216
+ const published = await publish();
217
+ const archive = path.join(published.dir, "app", `${published.tarSha}.tar.gz`);
218
+ const release = await read(published);
219
+ const send = () => release.withArchive(release.record(null), async () => "sent");
220
+
221
+ await writeFile(archive, "the release's gzip tar!");
222
+ await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
223
+ await writeFile(archive, "the release's gzip taR");
224
+ await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
225
+ });
226
+ });
227
+
228
+ const recordingTool = (
229
+ answer: (cmd: string[], env: Record<string, string>) => Promise<{ code: number; output: string }>,
230
+ ) => {
231
+ const calls: { cmd: string[]; env: Record<string, string> }[] = [];
232
+ const run: SignatureTool = async (cmd, { env = {} } = {}) => {
233
+ calls.push({ cmd, env });
234
+ return await answer(cmd, env);
235
+ };
236
+ return { calls, run };
237
+ };
238
+ const bom = String.fromCharCode(0xfeff);
239
+ const extractedTo = (cmd: string[]) => cmd[cmd.indexOf("-C") + 1] ?? "";
240
+
241
+ describe("DesktopRelease signer", () => {
242
+ const macTool = (codesign: { code: number; output: string }, { plist = true } = {}) =>
243
+ recordingTool(async (cmd) => {
244
+ if (cmd[0] === "/usr/bin/tar") {
245
+ if (plist) await Bun.write(path.join(extractedTo(cmd), "Akancad.app/Contents/Info.plist"), "<plist/>");
246
+ return { code: 1, output: "tar: _CodeSignature/*: Not found in archive" };
247
+ }
248
+ return codesign;
249
+ });
250
+
251
+ test("macOS: the TeamIdentifier codesign shows for the app in the archive", async () => {
252
+ const release = await read(await publish());
253
+ const { calls, run } = macTool({
254
+ code: 0,
255
+ output: "Executable=…\nTeamIdentifier=ABCDE12345\nSealed Resources=none\n",
256
+ });
257
+
258
+ expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({
259
+ signer: { teamId: "ABCDE12345" },
260
+ unread: null,
261
+ });
262
+ const [extraction, display] = calls;
263
+ expect(extraction?.cmd.slice(0, 3)).toEqual([
264
+ "/usr/bin/tar",
265
+ "-xzf",
266
+ path.join(release.dir, release.manifest.archive.url),
267
+ ]);
268
+ expect(extraction?.cmd.filter((_, idx, cmd) => cmd[idx - 1] === "--include")).toEqual([
269
+ "Akancad.app/Contents/Info.plist",
270
+ "Akancad.app/Contents/MacOS/*",
271
+ "Akancad.app/Contents/_CodeSignature/*",
272
+ ]);
273
+ expect(display?.cmd).toEqual([
274
+ "/usr/bin/codesign",
275
+ "--display",
276
+ "--verbose=2",
277
+ path.join(extractedTo(extraction?.cmd ?? []), "Akancad.app"),
278
+ ]);
279
+ });
280
+
281
+ test("macOS: an app signed ad hoc or not at all has no signer, and nothing to warn about", async () => {
282
+ const release = await read(await publish());
283
+ const adHoc = macTool({ code: 0, output: "Signature=adhoc\nTeamIdentifier=not set\n" });
284
+ const unsigned = macTool({ code: 1, output: "/tmp/x/Akancad.app: code object is not signed at all\n" });
285
+
286
+ for (const { run } of [adHoc, unsigned])
287
+ expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({ signer: null, unread: null });
288
+ });
289
+
290
+ test("macOS: says why when codesign or the archive cannot tell", async () => {
291
+ const release = await read(await publish());
292
+
293
+ expect(
294
+ await release.signer({ exeName: "akancad", host: "darwin", run: macTool({ code: 1, output: "boom\n" }).run }),
295
+ ).toEqual({ signer: null, unread: "codesign could not read Akancad.app: boom" });
296
+ expect(
297
+ await release.signer({
298
+ exeName: "akancad",
299
+ host: "darwin",
300
+ run: macTool({ code: 0, output: "" }, { plist: false }).run,
301
+ }),
302
+ ).toEqual({ signer: null, unread: "the archive holds no Akancad.app/Contents/Info.plist" });
303
+ const { calls, run } = macTool({ code: 0, output: "TeamIdentifier=ABCDE12345\n" });
304
+ expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({
305
+ signer: null,
306
+ unread: "codesign reads a macOS app's signer on macOS only",
307
+ });
308
+ expect(calls).toEqual([]);
309
+ });
310
+
311
+ const windowsTool = (powershell: (out: string) => Promise<number>) =>
312
+ recordingTool(async (cmd, env) => {
313
+ if (cmd[0] === "tar.exe") {
314
+ await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
315
+ return { code: 0, output: "" };
316
+ }
317
+ return { code: await powershell(env.AKAN_SIGNER_OUT), output: "" };
318
+ });
319
+
320
+ test("Windows: the Authenticode subject of the app's executable, read as UTF-8", async () => {
321
+ const release = await read(await publish({ platform: "windows", arch: "x64" }), {
322
+ platform: "windows",
323
+ arch: "x64",
324
+ });
325
+ const subject = "CN=아칸 주식회사, O=아칸 주식회사, C=KR";
326
+ const { calls, run } = windowsTool(async (out) => {
327
+ await Bun.write(out, `${bom}${subject}\r\n`);
328
+ return 0;
329
+ });
330
+
331
+ expect(await release.signer({ exeName: "akancad", host: "win32", run })).toEqual({
332
+ signer: { subject },
333
+ unread: null,
334
+ });
335
+ const [extraction, check] = calls;
336
+ expect(extraction?.cmd.at(-1)).toBe("akancad/akancad.exe");
337
+ expect(check?.env.AKAN_RELEASE_EXE).toBe(path.join(extractedTo(extraction?.cmd ?? []), "akancad/akancad.exe"));
338
+ expect(check?.cmd.slice(1, 4)).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand"]);
339
+ expect(Buffer.from(check?.cmd[4] ?? "", "base64").toString("utf16le")).toContain(
340
+ "Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE",
341
+ );
342
+ });
343
+
344
+ test("Windows: an executable without a valid signature has no signer; a PowerShell that fails says so", async () => {
345
+ const release = await read(await publish({ platform: "windows", arch: "x64" }), {
346
+ platform: "windows",
347
+ arch: "x64",
348
+ });
349
+ const unsigned = windowsTool(async (out) => {
350
+ await Bun.write(out, `${bom}\r\n`);
351
+ return 0;
352
+ });
353
+ const blocked = windowsTool(async () => 1);
354
+
355
+ expect(await release.signer({ exeName: "akancad", host: "win32", run: unsigned.run })).toEqual({
356
+ signer: null,
357
+ unread: null,
358
+ });
359
+ expect(await release.signer({ exeName: "akancad", host: "win32", run: blocked.run })).toEqual({
360
+ signer: null,
361
+ unread: "PowerShell could not read the Authenticode signature of akancad/akancad.exe",
362
+ });
363
+ expect(await release.signer({ exeName: "akancad", host: "darwin", run: unsigned.run })).toEqual({
364
+ signer: null,
365
+ unread: "PowerShell reads a Windows app's Authenticode signer on Windows only",
366
+ });
367
+ });
368
+
369
+ test("Linux: no signer, and nothing run", async () => {
370
+ const release = await read(await publish({ platform: "linux", arch: "x64" }), { platform: "linux", arch: "x64" });
371
+ const { calls, run } = recordingTool(async () => ({ code: 0, output: "" }));
372
+
373
+ expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({ signer: null, unread: null });
374
+ expect(calls).toEqual([]);
375
+ });
376
+
377
+ test.skipIf(process.platform !== "darwin")(
378
+ "macOS: reads an ad hoc signature from the archive itself with the system tar and codesign",
379
+ async () => {
380
+ const build = await makeDir();
381
+ const contents = path.join(build, "Akancad.app", "Contents");
382
+ await mkdir(path.join(contents, "MacOS"), { recursive: true });
383
+ await mkdir(path.join(contents, "Resources"), { recursive: true });
384
+ await writeFile(path.join(contents, "Resources", "server.json"), "{}");
385
+ await writeFile(
386
+ path.join(contents, "Info.plist"),
387
+ '<?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',
388
+ );
389
+ await writeFile(path.join(contents, "MacOS", "akancad"), await readFile("/usr/bin/true"));
390
+ const signed = Bun.spawnSync(["/usr/bin/codesign", "--force", "--sign", "-", path.join(build, "Akancad.app")]);
391
+ expect(signed.exitCode).toBe(0);
392
+ const gz = path.join(build, "release.tar.gz");
393
+ const tarred = Bun.spawnSync(["/usr/bin/tar", "--no-mac-metadata", "-czf", gz, "-C", build, "Akancad.app"]);
394
+ expect(tarred.exitCode).toBe(0);
395
+ const published = await publish();
396
+ await writeFile(path.join(published.dir, "app", `${published.tarSha}.tar.gz`), await readFile(gz));
397
+ const release = await read(published);
398
+
399
+ expect(await release.signer({ exeName: "akancad" })).toEqual({ signer: null, unread: null });
400
+ },
401
+ );
402
+ });
@@ -0,0 +1,271 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { isRecord, toError } from "akanjs/common";
6
+ import type { UpdateManifest } from "akanjs/native/api";
7
+ import { AkanAppConfig } from "../akanConfig";
8
+ import type { DesktopArch } from "./desktopBin";
9
+ import type { DesktopPlatform } from "./mobileTarget";
10
+ import { TarWriter } from "./tarWriter";
11
+ import { UpdateUploader } from "./updateUploader";
12
+
13
+ export type DesktopSigner = { teamId: string } | { subject: string } | null;
14
+
15
+ /** `release.json`, the one file of a desktop release archive `publish-update` did not write. */
16
+ export interface DesktopReleaseRecord {
17
+ schema: 1;
18
+ cli: string;
19
+ platform: DesktopPlatform;
20
+ arch: DesktopArch;
21
+ channel: string;
22
+ signer: DesktopSigner;
23
+ builtAt: string;
24
+ }
25
+
26
+ export type SignatureTool = (
27
+ cmd: string[],
28
+ options?: { env?: Record<string, string> },
29
+ ) => Promise<{ code: number; output: string }>;
30
+
31
+ type DesktopManifest = Partial<UpdateManifest> &
32
+ Required<Pick<UpdateManifest, "archive" | "build" | "bundle" | "sequence">>;
33
+
34
+ interface DesktopReleasePayload {
35
+ name: string;
36
+ size: number;
37
+ sha256: string;
38
+ }
39
+
40
+ //* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed manifest and the
41
+ //* files it names, packed as they are, so the same folder uploaded anywhere else stays valid.
42
+ export class DesktopRelease {
43
+ static readonly #ed25519Spki = Buffer.from("302a300506032b6570032100", "hex");
44
+ //? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
45
+ //? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
46
+ static readonly #authenticode = `$ErrorActionPreference = 'Stop'
47
+ $ProgressPreference = 'SilentlyContinue'
48
+ $signature = Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE
49
+ $subject = if ("$($signature.Status)" -ceq 'Valid') { "$($signature.SignerCertificate.Subject)" } else { '' }
50
+ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
51
+
52
+ private constructor(
53
+ readonly dir: string,
54
+ readonly manifest: DesktopManifest,
55
+ readonly payloads: DesktopReleasePayload[],
56
+ readonly platform: DesktopPlatform,
57
+ readonly arch: DesktopArch,
58
+ readonly channel: string,
59
+ readonly manifestBytes: Uint8Array<ArrayBuffer>,
60
+ readonly signatureBytes: Uint8Array<ArrayBuffer>,
61
+ ) {}
62
+
63
+ static async read(
64
+ updatesDir: string,
65
+ {
66
+ platform,
67
+ arch,
68
+ channel,
69
+ appId,
70
+ publicKey,
71
+ }: { platform: DesktopPlatform; arch: DesktopArch; channel: string; appId: string; publicKey: string },
72
+ ) {
73
+ const folder = `${platform}-${arch}`;
74
+ const dir = path.join(updatesDir, folder);
75
+ const manifestPath = path.join(dir, `${channel}.json`);
76
+ const [manifestBytes, signatureBytes] = await Promise.all([
77
+ readFile(manifestPath).catch(() => null),
78
+ readFile(`${manifestPath}.sig`).catch(() => null),
79
+ ]);
80
+ if (!manifestBytes || !signatureBytes)
81
+ throw new Error(
82
+ `${manifestBytes ? `${manifestPath}.sig` : manifestPath} is missing: run \`akan publish-update\` first.`,
83
+ );
84
+ if (!DesktopRelease.#verifies(manifestBytes, signatureBytes, publicKey))
85
+ throw new Error(
86
+ `${manifestPath}.sig does not verify with updates.publicKeys[0], the one key installed apps trust: publish the release again.`,
87
+ );
88
+ const manifest = DesktopRelease.#parse(manifestBytes);
89
+ if (
90
+ manifest?.schema !== 1 ||
91
+ manifest.kind !== "app" ||
92
+ manifest.app !== appId ||
93
+ manifest.platform !== platform ||
94
+ manifest.arch !== arch ||
95
+ manifest.channel !== channel ||
96
+ typeof manifest.bundle !== "string" ||
97
+ typeof manifest.sequence !== "number" ||
98
+ typeof manifest.build !== "number" ||
99
+ !manifest.archive
100
+ )
101
+ throw new Error(`${manifestPath} is not a release of ${appId} for ${folder} on channel ${channel}.`);
102
+ const { archive, bundle, sequence, build } = manifest;
103
+ const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
104
+ const expected = new Map(
105
+ [{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
106
+ ({ url, size, sha256 }) => [path.posix.normalize(url), { size, sha256 }],
107
+ ),
108
+ );
109
+ const payloads = keys.flatMap((key) => {
110
+ const name = key.slice(folder.length + 1);
111
+ const digest = expected.get(name);
112
+ return digest ? [{ name, ...digest }] : [];
113
+ });
114
+ return new DesktopRelease(
115
+ dir,
116
+ { ...manifest, archive, bundle, sequence, build },
117
+ payloads,
118
+ platform,
119
+ arch,
120
+ channel,
121
+ manifestBytes,
122
+ signatureBytes,
123
+ );
124
+ }
125
+
126
+ record(signer: DesktopSigner): DesktopReleaseRecord {
127
+ const { platform, arch, channel } = this;
128
+ const builtAt = new Date(this.manifest.build * 1000).toISOString();
129
+ return { schema: 1, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
130
+ }
131
+
132
+ //* Akan Cloud refuses every push in any other shape: uncompressed, regular files under `./` names, no directory
133
+ //* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json and the manifest pair before
134
+ //* any `app/` entry. What the manifest names is checked against it as it is copied.
135
+ async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
136
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
137
+ try {
138
+ const archive = path.join(tmp, "release.tar");
139
+ const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
140
+ try {
141
+ await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
142
+ await tar.add(`./${this.channel}.json`, new Blob([this.manifestBytes]));
143
+ await tar.add(`./${this.channel}.json.sig`, new Blob([this.signatureBytes]));
144
+ for (const { name, size, sha256 } of this.payloads) {
145
+ const file = Bun.file(path.join(this.dir, name));
146
+ const what = `${path.join(this.dir, name)} is not the file ${this.channel}.json names`;
147
+ if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
148
+ if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
149
+ }
150
+ await tar.close();
151
+ } catch (error) {
152
+ await tar.discard();
153
+ throw error;
154
+ }
155
+ return await use(archive);
156
+ } finally {
157
+ await rm(tmp, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ async signer({
162
+ exeName,
163
+ host = process.platform,
164
+ run = DesktopRelease.#run,
165
+ }: {
166
+ exeName: string;
167
+ host?: NodeJS.Platform;
168
+ run?: SignatureTool;
169
+ }): Promise<{ signer: DesktopSigner; unread: string | null }> {
170
+ if (this.platform === "linux") return { signer: null, unread: null };
171
+ const app = this.manifest.files?.[0]?.path;
172
+ if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
173
+ return { signer: null, unread: `${this.channel}.json names no app folder` };
174
+ if (this.platform === "macos" && host !== "darwin")
175
+ return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
176
+ if (this.platform === "windows" && host !== "win32")
177
+ return { signer: null, unread: "PowerShell reads a Windows app's Authenticode signer on Windows only" };
178
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-signer-"));
179
+ try {
180
+ const archive = path.join(this.dir, this.manifest.archive.url);
181
+ return this.platform === "macos"
182
+ ? await DesktopRelease.#teamOf(run, archive, tmp, app)
183
+ : await DesktopRelease.#subjectOf(run, archive, tmp, path.posix.join(app, `${exeName}.exe`));
184
+ } finally {
185
+ await rm(tmp, { recursive: true, force: true });
186
+ }
187
+ }
188
+
189
+ //? The signature lives in the executable and _CodeSignature; Info.plist names the executable. An app signed without
190
+ //? resources has no _CodeSignature, and bsdtar then exits 1 for the unmatched --include though it wrote the rest.
191
+ static async #teamOf(run: SignatureTool, archive: string, tmp: string, app: string) {
192
+ const contents = `${app}/Contents`;
193
+ await run([
194
+ "/usr/bin/tar",
195
+ "-xzf",
196
+ archive,
197
+ "-C",
198
+ tmp,
199
+ ...["Info.plist", "MacOS/*", "_CodeSignature/*"].flatMap((entry) => ["--include", `${contents}/${entry}`]),
200
+ ]);
201
+ if (!(await Bun.file(path.join(tmp, contents, "Info.plist")).exists()))
202
+ return { signer: null, unread: `the archive holds no ${contents}/Info.plist` };
203
+ const { output } = await run(["/usr/bin/codesign", "--display", "--verbose=2", path.join(tmp, app)]);
204
+ const teamId = /^TeamIdentifier=([A-Z0-9]{10})$/m.exec(output)?.[1];
205
+ if (teamId) return { signer: { teamId }, unread: null };
206
+ if (/^TeamIdentifier=not set$/m.test(output) || /code object is not signed at all/.test(output))
207
+ return { signer: null, unread: null };
208
+ return { signer: null, unread: `codesign could not read ${app}: ${output.trim().split("\n")[0] ?? ""}` };
209
+ }
210
+
211
+ static async #subjectOf(run: SignatureTool, archive: string, tmp: string, exe: string) {
212
+ await run(["tar.exe", "-xzf", archive, "-C", tmp, exe]);
213
+ const extracted = path.join(tmp, exe);
214
+ if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
215
+ const out = path.join(tmp, "signer.txt");
216
+ const { code } = await run(
217
+ [
218
+ path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
219
+ "-NoProfile",
220
+ "-NonInteractive",
221
+ "-EncodedCommand",
222
+ Buffer.from(DesktopRelease.#authenticode, "utf16le").toString("base64"),
223
+ ],
224
+ { env: { AKAN_RELEASE_EXE: extracted, AKAN_SIGNER_OUT: out } },
225
+ );
226
+ if (code !== 0) return { signer: null, unread: `PowerShell could not read the Authenticode signature of ${exe}` };
227
+ const subject = (await readFile(out, "utf8").catch(() => "")).replace(/^\uFEFF/, "").trim();
228
+ return { signer: subject ? { subject } : null, unread: null };
229
+ }
230
+
231
+ static #verifies(manifest: Uint8Array, signature: Uint8Array, publicKey: string) {
232
+ try {
233
+ const key = createPublicKey({
234
+ key: Buffer.concat([DesktopRelease.#ed25519Spki, Buffer.from(publicKey, "base64")]),
235
+ format: "der",
236
+ type: "spki",
237
+ });
238
+ return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ static #parse(bytes: Uint8Array): Partial<UpdateManifest> | null {
245
+ try {
246
+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
247
+ return isRecord(value) ? (value as Partial<UpdateManifest>) : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ static async #run(cmd: string[], { env }: { env?: Record<string, string> } = {}) {
254
+ try {
255
+ const proc = Bun.spawn(cmd, {
256
+ env: { ...process.env, ...env },
257
+ stdin: "ignore",
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ });
261
+ const [code, stdout, stderr] = await Promise.all([
262
+ proc.exited,
263
+ new Response(proc.stdout).text(),
264
+ new Response(proc.stderr).text(),
265
+ ]);
266
+ return { code, output: `${stdout}${stderr}` };
267
+ } catch (error) {
268
+ return { code: -1, output: toError(error).message };
269
+ }
270
+ }
271
+ }
package/mobile/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./desktopBin";
2
+ export * from "./desktopRelease";
2
3
  export * from "./desktopServerStage";
3
4
  export * from "./mobileTarget";
4
5
  export * from "./nativeApi";
@@ -8,6 +9,7 @@ export * from "./nativePluginFolders";
8
9
  export * from "./nativePublicEnv";
9
10
  export * from "./nativeWebDir";
10
11
  export * from "./placeholderAppId";
12
+ export * from "./tarWriter";
11
13
  export * from "./updatePackFolder";
12
14
  export * from "./updateUploader";
13
15
  export * from "./usageDescriptions";
@@ -5,6 +5,7 @@ import type { AkanNativeConfig } from "akanjs/native/config";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { Executor } from "../executors";
7
7
  import { type DesktopArch, DesktopBin } from "./desktopBin";
8
+ import { DesktopRelease } from "./desktopRelease";
8
9
  import type { DesktopServerBundle } from "./desktopServerStage";
9
10
  import {
10
11
  type DesktopPlatform,
@@ -311,6 +312,22 @@ export class NativeApp {
311
312
  return path.join(this.targetRoot, "updates");
312
313
  }
313
314
 
315
+ //? The channel falls back as publishUpdate's does, so a push with the flags of the publish finds what it wrote.
316
+ async publishedRelease(platform: DesktopPlatform, { arch, channel }: { arch: DesktopArch; channel?: string | null }) {
317
+ this.#assertUpdates();
318
+ const { config } = await this.config({ platform, stageBin: false });
319
+ const publicKey = config.updates?.publicKeys?.[0] ?? config.updates?.publicKey;
320
+ if (!config.updates || !publicKey) throw new Error(`Native target '${this.target.name}' has no updates key.`);
321
+ const release = await DesktopRelease.read(this.updatesDir, {
322
+ platform,
323
+ arch,
324
+ channel: channel ?? config.updates.channel ?? "production",
325
+ appId: config.app.id,
326
+ publicKey,
327
+ });
328
+ return { release, url: config.updates.url, exeName: config.app.fileName ?? "" };
329
+ }
330
+
314
331
  /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
315
332
  async publishUpdate(
316
333
  platform: NativePlatform,
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { TarWriter } from "./tarWriter";
7
+
8
+ const makeDir = tempDirs("akan-tar-writer-");
9
+ const delta = `./app/${"a".repeat(64)}-${"b".repeat(64)}.delta.gz`;
10
+ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
11
+
12
+ const write = async (file: string, entries: [string, string][], mtime = 1_761_700_000) => {
13
+ const tar = await TarWriter.create(file, { mtime });
14
+ const digests: string[] = [];
15
+ for (const [name, body] of entries) digests.push(await tar.add(name, new Blob([body])));
16
+ await tar.close();
17
+ return digests;
18
+ };
19
+
20
+ describe("TarWriter", () => {
21
+ test("a system tar reads back every file under its name, a 144-byte one too, and lists nothing else", async () => {
22
+ const dir = await makeDir();
23
+ const file = path.join(dir, "release.tar");
24
+ const entries: [string, string][] = [
25
+ ["./release.json", '{"schema":1}\n'],
26
+ [delta, "x".repeat(1_000)],
27
+ ];
28
+
29
+ const digests = await write(file, entries);
30
+
31
+ expect(digests).toEqual(entries.map(([, body]) => sha256(body)));
32
+ const listed = Bun.spawnSync(["tar", "-tf", file]).stdout.toString().split(/\r?\n/).filter(Boolean);
33
+ expect(listed).toEqual(entries.map(([name]) => name));
34
+ const out = path.join(dir, "out");
35
+ await mkdir(out);
36
+ expect(Bun.spawnSync(["tar", "-xf", file, "-C", out]).exitCode).toBe(0);
37
+ for (const [name, body] of entries) expect(await readFile(path.join(out, name), "utf8")).toBe(body);
38
+ });
39
+
40
+ test("writes ustar headers of mode 0644, owner 0 and the given mtime, padded to 512-byte blocks", async () => {
41
+ const dir = await makeDir();
42
+ const file = path.join(dir, "release.tar");
43
+
44
+ await write(file, [["./a.json", "{}"]], 1_761_700_000);
45
+
46
+ const bytes = await readFile(file);
47
+ const field = (at: number, width: number) => bytes.subarray(at, at + width).toString("latin1");
48
+ expect(field(0, 9)).toBe("./a.json\0");
49
+ expect(field(100, 8)).toBe("0000644\0");
50
+ expect(field(108, 16)).toBe("0000000\x000000000\0");
51
+ expect(Number.parseInt(field(136, 11), 8)).toBe(1_761_700_000);
52
+ expect(field(156, 1)).toBe("0");
53
+ expect(field(257, 8)).toBe("ustar\x0000");
54
+ expect(bytes.length).toBe(512 + 512 + 1024);
55
+ expect(bytes.subarray(1024).every((byte) => byte === 0)).toBe(true);
56
+ });
57
+
58
+ test("the same files and mtime make the same archive", async () => {
59
+ const dir = await makeDir();
60
+ const entries: [string, string][] = [
61
+ ["./main.json", "{}\n"],
62
+ [delta, "patch"],
63
+ ];
64
+
65
+ await write(path.join(dir, "one.tar"), entries);
66
+ await write(path.join(dir, "two.tar"), entries);
67
+
68
+ expect(sha256(await readFile(path.join(dir, "one.tar")))).toBe(sha256(await readFile(path.join(dir, "two.tar"))));
69
+ });
70
+
71
+ test("refuses a file whose length is not the one it was announced with", async () => {
72
+ const dir = await makeDir();
73
+ const tar = await TarWriter.create(path.join(dir, "release.tar"));
74
+ const grown = { size: 3, stream: () => new Blob(["abcdef"]).stream() } as unknown as Blob;
75
+
76
+ await expect(tar.add("./app/x.tar.gz", grown)).rejects.toThrow(
77
+ "./app/x.tar.gz changed while it was archived: 6 bytes, not 3.",
78
+ );
79
+ await tar.discard();
80
+ });
81
+
82
+ test("never overwrites an existing file", async () => {
83
+ const file = path.join(await makeDir(), "release.tar");
84
+ await Bun.write(file, "kept");
85
+
86
+ await expect(TarWriter.create(file)).rejects.toThrow();
87
+ expect(await readFile(file, "utf8")).toBe("kept");
88
+ });
89
+ });
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type FileHandle, open } from "node:fs/promises";
3
+
4
+ //* An uncompressed POSIX tar of regular files, the same bytes on every OS. A system tar also writes what the host keeps
5
+ //* beside a file (macOS bsdtar an AppleDouble `._` entry for extended attributes), which a receiver refusing unlisted
6
+ //* entries rejects. A name past the ustar field's 100 bytes travels in a pax `path` record before its header.
7
+ export class TarWriter {
8
+ static readonly #block = 512;
9
+ static readonly #nameBytes = 100;
10
+ //? The size field holds 11 octal digits.
11
+ static readonly #maxSize = 8 ** 11 - 1;
12
+ readonly #handle: FileHandle;
13
+ readonly #mtime: number;
14
+
15
+ private constructor(handle: FileHandle, mtime: number) {
16
+ this.#handle = handle;
17
+ this.#mtime = mtime;
18
+ }
19
+
20
+ //? `mtime` (seconds) goes on every entry, so the same files make the same archive.
21
+ static async create(file: string, { mtime = 0 }: { mtime?: number } = {}) {
22
+ return new TarWriter(await open(file, "wx"), mtime);
23
+ }
24
+
25
+ async add(name: string, source: Blob): Promise<string> {
26
+ const { size } = source;
27
+ if (size > TarWriter.#maxSize) throw new Error(`${name} is ${size} bytes, more than a tar entry holds.`);
28
+ if (Buffer.byteLength(name) > TarWriter.#nameBytes) {
29
+ const pax = Buffer.from(TarWriter.#paxRecord("path", name));
30
+ await this.#write(
31
+ TarWriter.#header(`./PaxHeaders/${name.split("/").at(-1) ?? ""}`, pax.length, this.#mtime, "x"),
32
+ );
33
+ await this.#write(pax);
34
+ await this.#pad(pax.length);
35
+ }
36
+ await this.#write(TarWriter.#header(name, size, this.#mtime, "0"));
37
+ const hasher = createHash("sha256");
38
+ let written = 0;
39
+ for await (const chunk of source.stream()) {
40
+ hasher.update(chunk);
41
+ await this.#write(chunk);
42
+ written += chunk.length;
43
+ }
44
+ if (written !== size) throw new Error(`${name} changed while it was archived: ${written} bytes, not ${size}.`);
45
+ await this.#pad(size);
46
+ return hasher.digest("hex");
47
+ }
48
+
49
+ async close() {
50
+ await this.#write(new Uint8Array(TarWriter.#block * 2));
51
+ await this.#handle.close();
52
+ }
53
+
54
+ async discard() {
55
+ await this.#handle.close().catch(() => undefined);
56
+ }
57
+
58
+ async #write(bytes: Uint8Array) {
59
+ for (let at = 0; at < bytes.length; ) at += (await this.#handle.write(bytes, at)).bytesWritten;
60
+ }
61
+
62
+ async #pad(size: number) {
63
+ const rest = size % TarWriter.#block;
64
+ if (rest) await this.#write(new Uint8Array(TarWriter.#block - rest));
65
+ }
66
+
67
+ //? A record's length counts its own digits: `<length> <key>=<value>\n`.
68
+ static #paxRecord(key: string, value: string) {
69
+ const body = Buffer.byteLength(` ${key}=${value}\n`);
70
+ let length = body + 1;
71
+ while (body + String(length).length !== length) length = body + String(length).length;
72
+ return `${length} ${key}=${value}\n`;
73
+ }
74
+
75
+ static #header(name: string, size: number, mtime: number, type: "0" | "x") {
76
+ const header = Buffer.alloc(TarWriter.#block);
77
+ header.write(name, 0, TarWriter.#nameBytes, "utf8");
78
+ const octal = (value: number, at: number, width: number) =>
79
+ header.write(`${value.toString(8).padStart(width - 1, "0")}\0`, at, width, "latin1");
80
+ octal(0o644, 100, 8);
81
+ octal(0, 108, 8);
82
+ octal(0, 116, 8);
83
+ octal(size, 124, 12);
84
+ octal(mtime, 136, 12);
85
+ header.write(" ", 148, 8, "latin1");
86
+ header.write(type, 156, 1, "latin1");
87
+ header.write("ustar\0", 257, 6, "latin1");
88
+ header.write("00", 263, 2, "latin1");
89
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
90
+ header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "latin1");
91
+ return header;
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.6",
3
+ "version": "3.1.1-rc.8",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.6",
51
+ "akanjs": "3.1.1-rc.8",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",