Open History to see the last commit
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 Effect.scoped,
88 ),
89 );
90
91 it.effect("reuses the default askpass directory across interactive commands", () =>
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const askpassPath = () =>
96 buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
97 Effect.map((env) => env.SSH_ASKPASS ?? ""),
98 );
99 const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));
100
101 const first = yield* askpassPath();
102 assert.equal(yield* askpassPath(), first);
103
104 yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
105 const recreated = yield* askpassPath();
106 assert.notEqual(recreated, first);
107 assert.equal(yield* fs.exists(recreated), true);
108
109 const cachedOwner = yield* fs.stat(runtimeDirectoryOf(recreated));
110 const otherUser = yield* askpassPath().pipe(
111 Effect.provideService(
112 HostProcess.UserId,
113 (Option.getOrUndefined(cachedOwner.uid) ?? 0) + 1,
114 ),
115 );
116 assert.notEqual(otherUser, recreated);
117
118 yield* fs.remove(runtimeDirectoryOf(recreated), { recursive: true });
119 yield* fs.remove(runtimeDirectoryOf(otherUser), { recursive: true });
120 }).pipe(
121 Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
122 ),
123 );
124
125 it.effect.skipIf(!symlinksSupported)(
126 "does not reuse a symlink left at the default askpass directory path",
127 () =>
128 Effect.gen(function* () {
129 const fs = yield* FileSystem.FileSystem;
130 const path = yield* Path.Path;
131 const askpassPath = () =>
132 buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
133 Effect.map((env) => env.SSH_ASKPASS ?? ""),
134 );
135 const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));
136
137 const first = yield* askpassPath();
138 const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" });
139 yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
140 yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first));
141
142 const next = yield* askpassPath();
143 assert.notEqual(next, first);
144 assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false);
145
146 yield* fs.remove(runtimeDirectoryOf(first));
147 yield* fs.remove(ownedTarget, { recursive: true });
148 yield* fs.remove(runtimeDirectoryOf(next), { recursive: true });
149 }).pipe(
150 Effect.provide(
151 Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux")),
152 ),
153 ),
154 );
155
156 it.effect("writes the default askpass helper once when interactive commands overlap", () =>
157 Effect.gen(function* () {
158 const fs = yield* FileSystem.FileSystem;
159 const runtimeDirectories: Array<string> = [];
160 yield* Effect.addFinalizer(() =>
161 Effect.forEach(runtimeDirectories, (directory) =>
162 fs.remove(directory, { recursive: true }).pipe(Effect.ignore),
163 ),
164 );
165
166 // The first helper write of each round waits for the test, so the second command starts
167 // while the first is still setting up and must not touch the shared files until it is done.
168 let gate: { arrived: Deferred.Deferred<void>; release: Deferred.Deferred<void> } | undefined;
169 let fileCalls = 0;
170 let helperWrites = 0;
171 const counted = <A, E>(effect: Effect.Effect<A, E>) =>
172 Effect.suspend(() => {
173 fileCalls += 1;
174 return effect;
175 });
176 const trackedFs: FileSystem.FileSystem = {
177 ...fs,
178 makeTempDirectory: (options) =>
179 counted(fs.makeTempDirectory(options)).pipe(
180 Effect.tap((directory) => Effect.sync(() => runtimeDirectories.push(directory))),
181 ),
182 stat: (path) => counted(fs.stat(path)),
183 exists: (path) => counted(fs.exists(path)),
184 writeFileString: (path, data, options) =>
185 counted(
186 Effect.gen(function* () {
187 helperWrites += 1;
188 const held = gate;
189 gate = undefined;
190 if (held) {
191 yield* Deferred.succeed(held.arrived, undefined);
192 yield* Deferred.await(held.release);
193 }
194 yield* fs.writeFileString(path, data, options);
195 }),
196 ),
197 };
198 const askpassPath = buildSshChildEnvironment({
199 interactiveAuth: true,
200 authSecret: "pw",
201 }).pipe(
202 Effect.map((env) => env.SSH_ASKPASS ?? ""),
203 Effect.provideService(FileSystem.FileSystem, trackedFs),
204 );
205 const twoOverlappingCommands = Effect.gen(function* () {
206 const held = {
207 arrived: yield* Deferred.make<void>(),
208 release: yield* Deferred.make<void>(),
209 };
210 gate = held;
211 const first = yield* Effect.forkChild(askpassPath);
212 yield* Deferred.await(held.arrived);
213 const callsBefore = fileCalls;
214 const second = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true }));
215 assert.equal(fileCalls, callsBefore, "second command touched the files mid-setup");
216 yield* Deferred.succeed(held.release, undefined);
217 return [yield* Fiber.join(first), yield* Fiber.join(second)] as const;
218 });
219
220 const [first, second] = yield* twoOverlappingCommands;
221 assert.equal(second, first);
222 assert.equal(runtimeDirectories.length, 1);
223 assert.equal(helperWrites, 1);
224
225 yield* fs.remove(first);
226 assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]);
227 assert.equal(runtimeDirectories.length, 1);
228 assert.equal(helperWrites, 2);
229
230 yield* fs.remove(first);
231 const held = {
232 arrived: yield* Deferred.make<void>(),
233 release: yield* Deferred.make<void>(),
234 };
235 gate = held;
236 const cancelled = yield* Effect.forkChild(askpassPath);
237 yield* Deferred.await(held.arrived);
238 const callsBeforeCancellation = fileCalls;
239 const interruption = yield* Fiber.interrupt(cancelled).pipe(
240 Effect.forkChild({ startImmediately: true }),
241 );
242 const next = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true }));
243 const callsDuringCancellation = fileCalls;
244 yield* Deferred.succeed(held.release, undefined);
245 yield* Fiber.join(interruption);
246 assert.equal(yield* Fiber.join(next), first);
247 assert.equal(callsDuringCancellation, callsBeforeCancellation);
248 assert.equal(helperWrites, 3);
249 assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"');
250 }).pipe(
251 Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
252 Effect.scoped,
253 ),
254 );
255});