1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as Clock from "effect/Clock";
5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15
16import { signWithJsign } from "./windows-jsign.ts";
17
18const UNSIGNED = "MZ unsigned payload";
19const TRANSIENT_CHAIN =
20 "jsign: Unable to retrieve the certificate chain 'acct/profile'\nCaused by: java.io.IOException: InternalError - An internal error has occurred.\n";
21const TRANSIENT_SIGN =
22 "jsign: Couldn't sign t3.exe\nCaused by: java.security.GeneralSecurityException: java.io.IOException: InternalError - An internal error has occurred.\n";
23const BAD_SECRET =
24 "jsign: Unable to retrieve the certificate chain 'acct/profile'\nCaused by: java.io.IOException: AADSTS7000215: Invalid client secret provided.\n";
25
26/** What the stub does with the file jsign was given: sign it, or write junk and fail. */
27type Reply = { readonly exitCode: number; readonly stderr?: string; readonly write?: string };
28
29/** Stands in for jsign. Records the bytes it received and applies each reply in turn. */
30function stubJsign(replies: ReadonlyArray<Reply>) {
31 const inputs: Array<{ readonly path: string; readonly content: string }> = [];
32 const spawner = ChildProcessSpawner.make((command) =>
33 Effect.gen(function* () {
34 const fs = yield* FileSystem.FileSystem;
35 const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : [];
36 const target = args.at(-1)!;
37 inputs.push({ path: target, content: yield* fs.readFileString(target) });
38 const reply = replies[inputs.length - 1] ?? replies.at(-1)!;
39 if (reply.write !== undefined) yield* fs.writeFileString(target, reply.write);
40 return ChildProcessSpawner.makeHandle({
41 pid: ChildProcessSpawner.ProcessId(1),
42 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(reply.exitCode)),
43 isRunning: Effect.succeed(false),
44 kill: () => Effect.void,
45 unref: Effect.succeed(Effect.void),
46 stdin: Sink.drain,
47 stdout: Stream.empty,
48 stderr: reply.stderr ? Stream.make(new TextEncoder().encode(reply.stderr)) : Stream.empty,
49 all: Stream.empty,
50 getInputFd: () => Sink.drain,
51 getOutputFd: () => Stream.empty,
52 });
53 }).pipe(Effect.provide(NodeServices.layer), Effect.orDie),
54 );
55 return { inputs, spawner };
56}
57
58const signed = (content: string): Reply => ({ exitCode: 0, write: `${content}+signature` });
59
60const runSign = Effect.fn("test.runSign")(function* (replies: ReadonlyArray<Reply>) {
61 const fs = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-jsign-" });
64 const file = path.join(dir, "t3.exe");
65 yield* fs.writeFileString(file, UNSIGNED);
66 const stub = stubJsign(replies);
67 const sleeps: Array<number> = [];
68 const clock = yield* Clock.Clock;
69 const exit = yield* signWithJsign(file).pipe(
70 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, stub.spawner),
71 // Records the backoff instead of waiting it out.
72 Effect.provideService(Clock.Clock, {
73 ...clock,
74 sleep: (duration) => Effect.sync(() => sleeps.push(Duration.toSeconds(duration))),
75 }),
76 Effect.provide(
77 ConfigProvider.layer(
78 ConfigProvider.fromEnv({
79 env: {
80 AZURE_TRUSTED_SIGNING_ENDPOINT: "https://eus.codesigning.azure.net/",
81 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: "acct",
82 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: "profile",
83 },
84 }),
85 ),
86 ),
87 Effect.exit,
88 );
89 return {
90 ok: Exit.isSuccess(exit),
91 error: Exit.isFailure(exit)
92 ? Option.getOrUndefined(Cause.findErrorOption(exit.cause))
93 : undefined,
94 sleeps,
95 inputs: stub.inputs,
96 // jsign only ever sees a disposable copy, never the file itself.
97 signedInPlace: stub.inputs.some((input) => input.path === file),
98 content: yield* fs.readFileString(file),
99 // Only the original file is left: every attempt's copy is cleaned up.
100 entries: yield* fs.readDirectory(dir),
101 };
102});
103
104it.layer(NodeServices.layer)("signWithJsign", (it) => {
105 it.effect("retries transient Azure errors on a fresh copy and keeps only the signed file", () =>
106 Effect.gen(function* () {
107 const result = yield* runSign([
108 { exitCode: 1, stderr: TRANSIENT_CHAIN },
109 // A partial write must not reach the next attempt or the original.
110 { exitCode: 1, stderr: TRANSIENT_SIGN, write: "MZ half-written" },
111 signed(UNSIGNED),
112 ]);
113 assert.isTrue(result.ok);
114 assert.strictEqual(result.content, `${UNSIGNED}+signature`);
115 assert.deepStrictEqual(
116 result.inputs.map((input) => input.content),
117 [UNSIGNED, UNSIGNED, UNSIGNED],
118 );
119 assert.isFalse(result.signedInPlace);
120 assert.deepStrictEqual(result.sleeps, [5, 10]);
121 assert.deepStrictEqual(result.entries, ["t3.exe"]);
122 }),
123 );
124
125 it.effect("fails once without retrying on errors that are not transient", () =>
126 Effect.gen(function* () {
127 for (const reply of [
128 { exitCode: 1, stderr: BAD_SECRET },
129 { exitCode: 1, stderr: "jsign: Unsupported file: t3.exe\n", write: "MZ half-written" },
130 ]) {
131 const result = yield* runSign([reply, signed(UNSIGNED)]);
132 assert.strictEqual(result.error?._tag, "JsignFailedError");
133 assert.isFalse(result.error?._tag === "JsignFailedError" && result.error.transient);
134 assert.strictEqual(result.inputs.length, 1);
135 assert.deepStrictEqual(result.sleeps, []);
136 assert.strictEqual(result.content, UNSIGNED);
137 assert.deepStrictEqual(result.entries, ["t3.exe"]);
138 }
139 }),
140 );
141
142 it.effect("gives up after three retries and leaves the input unsigned", () =>
143 Effect.gen(function* () {
144 const result = yield* runSign([{ exitCode: 1, stderr: TRANSIENT_SIGN, write: "MZ junk" }]);
145 assert.isTrue(result.error?._tag === "JsignFailedError" && result.error.transient);
146 assert.strictEqual(result.inputs.length, 4);
147 assert.deepStrictEqual(result.sleeps, [5, 10, 20]);
148 assert.strictEqual(result.content, UNSIGNED);
149 assert.deepStrictEqual(result.entries, ["t3.exe"]);
150 }),
151 );
152});