Skip to content
139 changes: 139 additions & 0 deletions packages/ssh/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";

import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
Expand Down Expand Up @@ -50,4 +52,141 @@ describe("ssh auth", () => {
Effect.scoped,
),
);

it.effect("reuses the default askpass directory across interactive commands", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const askpassPath = () =>
buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
Effect.map((env) => env.SSH_ASKPASS ?? ""),
);
const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));

const first = yield* askpassPath();
assert.equal(yield* askpassPath(), first);

yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
const recreated = yield* askpassPath();
assert.notEqual(recreated, first);
assert.equal(yield* fs.exists(recreated), true);

const otherUser = yield* askpassPath().pipe(
Effect.provideService(HostProcess.UserId, (process.getuid?.() ?? 0) + 1),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
assert.notEqual(otherUser, recreated);

yield* fs.remove(runtimeDirectoryOf(recreated), { recursive: true });
yield* fs.remove(runtimeDirectoryOf(otherUser), { recursive: true });
}).pipe(
Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
),
);

it.effect("does not reuse a symlink left at the default askpass directory path", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const askpassPath = () =>
buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe(
Effect.map((env) => env.SSH_ASKPASS ?? ""),
);
const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass));

const first = yield* askpassPath();
const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" });
yield* fs.remove(runtimeDirectoryOf(first), { recursive: true });
yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first));

const next = yield* askpassPath();
assert.notEqual(next, first);
assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false);

yield* fs.remove(runtimeDirectoryOf(first));
yield* fs.remove(ownedTarget, { recursive: true });
yield* fs.remove(runtimeDirectoryOf(next), { recursive: true });
}).pipe(
Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

it.effect("writes the default askpass helper once when interactive commands overlap", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const runtimeDirectories: Array<string> = [];
yield* Effect.addFinalizer(() =>
Effect.forEach(runtimeDirectories, (directory) =>
fs.remove(directory, { recursive: true }).pipe(Effect.ignore),
),
);

// The first helper write of each round waits for the test, so the second command starts
// while the first is still setting up and must not touch the shared files until it is done.
let gate: { arrived: Deferred.Deferred<void>; release: Deferred.Deferred<void> } | undefined;
let fileCalls = 0;
let helperWrites = 0;
const counted = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.suspend(() => {
fileCalls += 1;
return effect;
});
const trackedFs: FileSystem.FileSystem = {
...fs,
makeTempDirectory: (options) =>
counted(fs.makeTempDirectory(options)).pipe(
Effect.tap((directory) => Effect.sync(() => runtimeDirectories.push(directory))),
),
stat: (path) => counted(fs.stat(path)),
exists: (path) => counted(fs.exists(path)),
writeFileString: (path, data, options) =>
counted(
Effect.gen(function* () {
helperWrites += 1;
const held = gate;
gate = undefined;
if (held) {
yield* Deferred.succeed(held.arrived, undefined);
yield* Deferred.await(held.release);
}
yield* fs.writeFileString(path, data, options);
}),
),
};
const askpassPath = buildSshChildEnvironment({
interactiveAuth: true,
authSecret: "pw",
}).pipe(
Effect.map((env) => env.SSH_ASKPASS ?? ""),
Effect.provideService(FileSystem.FileSystem, trackedFs),
);
const twoOverlappingCommands = Effect.gen(function* () {
const held = {
arrived: yield* Deferred.make<void>(),
release: yield* Deferred.make<void>(),
};
gate = held;
const first = yield* Effect.forkChild(askpassPath);
yield* Deferred.await(held.arrived);
const callsBefore = fileCalls;
const second = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true }));
assert.equal(fileCalls, callsBefore, "second command touched the files mid-setup");
yield* Deferred.succeed(held.release, undefined);
return [yield* Fiber.join(first), yield* Fiber.join(second)] as const;
});

const [first, second] = yield* twoOverlappingCommands;
assert.equal(second, first);
assert.equal(runtimeDirectories.length, 1);
assert.equal(helperWrites, 1);

yield* fs.remove(first);
assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]);
assert.equal(runtimeDirectories.length, 1);
assert.equal(helperWrites, 2);
assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"');
}).pipe(
Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))),
Effect.scoped,
),
);
});
57 changes: 46 additions & 11 deletions packages/ssh/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Semaphore from "effect/Semaphore";

import { SshPasswordPromptError } from "./errors.ts";

Expand Down Expand Up @@ -99,15 +100,6 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r
exit 1\r
`;

const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")(
function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" });
return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME);
},
);

const buildSshAskpassHelperDescriptor = Effect.fn("ssh/auth.buildSshAskpassHelperDescriptor")(
function* (input: {
readonly directory: string;
Expand Down Expand Up @@ -170,6 +162,47 @@ const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(fu
return descriptor.launcherPath;
});

// Nothing removes the runtime directory, so every interactive ssh command shares one per
// process. A fresh one replaces it if a temp cleaner deleted it or another user owns the path.
// The lock publishes a directory only after its helpers exist, so one command never rewrites
// a helper that another command has already handed to ssh.
let defaultSshAskpassRuntimeDirectory: string | undefined;
const defaultSshAskpassLock = Semaphore.makeUnsafe(1);

const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpassHelpers")(
function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const userId = yield* HostProcess.UserId;
const cached = defaultSshAskpassRuntimeDirectory;
if (cached !== undefined) {
const reusable = yield* Effect.all([fs.realPath(cached), fs.stat(cached)]).pipe(
Effect.map(
([resolved, info]) =>
resolved === cached &&
info.type === "Directory" &&
(userId === undefined || Option.getOrUndefined(info.uid) === userId),
),
Effect.orElseSucceed(() => false),
);
if (reusable) {
return yield* ensureSshAskpassHelpers({
directory: path.join(cached, SSH_ASKPASS_DIR_NAME),
});
}
}
const runtimeDirectory = yield* fs.realPath(
yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }),
);
const launcherPath = yield* ensureSshAskpassHelpers({
directory: path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME),
});
defaultSshAskpassRuntimeDirectory = runtimeDirectory;
return launcherPath;
},
(effect) => defaultSshAskpassLock.withPermit(effect),
);

export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* (
input: SshChildEnvironmentOptions = {},
): Effect.fn.Return<
Expand All @@ -190,8 +223,10 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron
Effect.orElseSucceed(() => Option.none<string>()),
Effect.map(Option.getOrUndefined),
);
const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory());
const sshAskpass = yield* ensureSshAskpassHelpers({ directory });
const sshAskpass =
input.askpassDirectory === undefined
? yield* ensureDefaultSshAskpassHelpers()
: yield* ensureSshAskpassHelpers({ directory: input.askpassDirectory });

return {
...baseEnv,
Expand Down
Loading