1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
13
14import { buildSshChildEnvironment, isSshAgentSigningFailure, isSshAuthFailure } from "./auth.ts";
15
16describe("ssh auth", () => {
17 it.effect("detects ssh auth failures from common permission denied messages", () =>
18 Effect.sync(() => {
19 assert.equal(
20 isSshAuthFailure(
21 new Error(
22 "julius@100.65.180.100: Permission denied (publickey,password,keyboard-interactive).",
23 ),
24 ),
25 true,
26 );
27 assert.equal(isSshAuthFailure(new Error("Permission denied (publickey).")), true);
28 assert.equal(isSshAuthFailure(new Error("Connection timed out")), false);
29 assert.equal(isSshAuthFailure(new Error("mkdir: Permission denied")), false);
30 }),
31 );
32
33 it.effect("detects agent signing failures from current and pre-8.2 OpenSSH", () =>
34 Effect.sync(() => {
35 const denied = "julius@devbox: Permission denied (publickey,password).";
36 assert.isTrue(
37 isSshAgentSigningFailure(
38 new Error(
39 `sign_and_send_pubkey: signing failed for ED25519 "/home/julius/.ssh/id_ed25519" from agent: communication with agent failed\r\n${denied}`,
40 ),
41 ),
42 );
43 assert.isTrue(
44 isSshAgentSigningFailure(
45 new Error(`sign_and_send_pubkey: signing failed: agent refused operation\n${denied}`),
46 ),
47 );
48 assert.isTrue(
49 isSshAgentSigningFailure(
50 new Error(
51 `sign_and_send_pubkey: signing failed: communication with agent failed\n${denied}`,
52 ),
53 ),
54 );
55 assert.isFalse(
56 isSshAgentSigningFailure(
57 new Error(
58 `sign_and_send_pubkey: signing failed for ED25519-SK "/home/julius/.ssh/id_ed25519_sk": device not found\n${denied}`,
59 ),
60 ),
61 );
62 assert.isFalse(isSshAgentSigningFailure(new Error(denied)));
63 }),
64 );
65
66 it.effect("creates askpass env for cached password prompts", () =>
67 Effect.gen(function* () {
68 const fs = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-askpass-test-" });
71 const env = yield* buildSshChildEnvironment({
72 authSecret: "super-secret",
73 interactiveAuth: true,
74 askpassDirectory: directory,
75 baseEnv: {},
76 });
77
78 const askpassPath = path.join(directory, "ssh-askpass.sh");
79 assert.equal(env.SSH_ASKPASS, askpassPath);
80 assert.equal(env.SSH_ASKPASS_REQUIRE, "force");
81 assert.equal(env.T3_SSH_AUTH_SECRET, "super-secret");
82 assert.equal(env.DISPLAY, "t3code");
83 assert.equal(yield* fs.exists(askpassPath), true);
84 assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"');
85 }).pipe(
86 Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
87 ),
88 );
89
90 it.effect("reuses the default askpass directory across interactive commands", () =>
91 Effect.gen(function* () {
92 const fs = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 const askpassPath = () =>
95 buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
96 Effect.map((env) => env.SSH_ASKPASS ?? ""),
97 );
98 const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));
99
100 const first = yield* askpassPath();
101 assert.equal(yield* askpassPath(), first);
102
103 yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
104 const recreated = yield* askpassPath();
105 assert.notEqual(recreated, first);
106 assert.equal(yield* fs.exists(recreated), true);
107
108 const cachedOwner = yield* fs.stat(runtimeDirectoryOf(recreated));
109 const otherUser = yield* askpassPath().pipe(
110 Effect.provideService(
111 HostProcess.UserId,
112 (Option.getOrUndefined(cachedOwner.uid) ?? 0) + 1,
113 ),
114 );
115 assert.notEqual(otherUser, recreated);
116
117 yield* fs.remove(runtimeDirectoryOf(recreated), { recursive: true });
118 yield* fs.remove(runtimeDirectoryOf(otherUser), { recursive: true });
119 }).pipe(
120 Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
121 ),
122 );
123
124 it.effect.skipIf(!symlinksSupported)(
125 "does not reuse a symlink left at the default askpass directory path",
126 () =>
127 Effect.gen(function* () {
128 const fs = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const askpassPath = () =>
131 buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
132 Effect.map((env) => env.SSH_ASKPASS ?? ""),
133 );
134 const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));
135
136 const first = yield* askpassPath();
137 const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" });
138 yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
139 yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first));
140
141 const next = yield* askpassPath();
142 assert.notEqual(next, first);
143 assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false);
144
145 yield* fs.remove(runtimeDirectoryOf(first));
146 yield* fs.remove(ownedTarget, { recursive: true });
147 yield* fs.remove(runtimeDirectoryOf(next), { recursive: true });
148 }).pipe(
149 Effect.provide(
150 Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux")),
151 ),
152 ),
153 );
154
155 it.effect("writes the default askpass helper once when interactive commands overlap", () =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const runtimeDirectories: Array<string> = [];
159 yield* Effect.addFinalizer(() =>
160 Effect.forEach(runtimeDirectories, (directory) =>
161 fs.remove(directory, { recursive: true }).pipe(Effect.ignore),
162 ),
163 );
164
165 // The first helper write of each round waits for the test, so the second command starts
166 // while the first is still setting up and must not touch the shared files until it is done.
167 let gate: { arrived: Deferred.Deferred<void>; release: Deferred.Deferred<void> } | undefined;
168 let fileCalls = 0;
169 let helperWrites = 0;
170 const counted = <A, E>(effect: Effect.Effect<A, E>) =>
171 Effect.suspend(() => {
172 fileCalls += 1;
173 return effect;
174 });
175 const trackedFs: FileSystem.FileSystem = {
176 ...fs,
177 makeTempDirectory: (options) =>
178 counted(fs.makeTempDirectory(options)).pipe(
179 Effect.tap((directory) => Effect.sync(() => runtimeDirectories.push(directory))),
180 ),
181 stat: (path) => counted(fs.stat(path)),
182 exists: (path) => counted(fs.exists(path)),
183 writeFileString: (path, data, options) =>
184 counted(
185 Effect.gen(function* () {
186 helperWrites += 1;
187 const held = gate;
188 gate = undefined;
189 if (held) {
190 yield* Deferred.succeed(held.arrived, undefined);
191 yield* Deferred.await(held.release);
192 }
193 yield* fs.writeFileString(path, data, options);
194 }),
195 ),
196 };
197 const askpassPath = buildSshChildEnvironment({
198 interactiveAuth: true,
199 authSecret: "pw",
200 }).pipe(
201 Effect.map((env) => env.SSH_ASKPASS ?? ""),
202 Effect.provideService(FileSystem.FileSystem, trackedFs),
203 );
204 const twoOverlappingCommands = Effect.gen(function* () {
205 const held = {
206 arrived: yield* Deferred.make<void>(),
207 release: yield* Deferred.make<void>(),
208 };
209 gate = held;
210 const first = yield* Effect.forkChild(askpassPath);
211 yield* Deferred.await(held.arrived);
212 const callsBefore = fileCalls;
213 const second = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true }));
214 assert.equal(fileCalls, callsBefore, "second command touched the files mid-setup");
215 yield* Deferred.succeed(held.release, undefined);
216 return [yield* Fiber.join(first), yield* Fiber.join(second)] as const;
217 });
218
219 const [first, second] = yield* twoOverlappingCommands;
220 assert.equal(second, first);
221 assert.equal(runtimeDirectories.length, 1);
222 assert.equal(helperWrites, 1);
223
224 yield* fs.remove(first);
225 assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]);
226 assert.equal(runtimeDirectories.length, 1);
227 assert.equal(helperWrites, 2);
228
229 yield* fs.remove(first);
230 const held = {
231 arrived: yield* Deferred.make<void>(),
232 release: yield* Deferred.make<void>(),
233 };
234 gate = held;
235 const cancelled = yield* Effect.forkChild(askpassPath);
236 yield* Deferred.await(held.arrived);
237 const callsBeforeCancellation = fileCalls;
238 const interruption = yield* Fiber.interrupt(cancelled).pipe(
239 Effect.forkChild({ startImmediately: true }),
240 );
241 const next = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true }));
242 const callsDuringCancellation = fileCalls;
243 yield* Deferred.succeed(held.release, undefined);
244 yield* Fiber.join(interruption);
245 assert.equal(yield* Fiber.join(next), first);
246 assert.equal(callsDuringCancellation, callsBeforeCancellation);
247 assert.equal(helperWrites, 3);
248 assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"');
249 }).pipe(
250 Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
251 ),
252 );
253});