Files
dev/third_party/pi/packages/env/test/ssh.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

300 lines
12 KiB
TypeScript

import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer, Socket } from "node:net";
import { tmpdir, userInfo } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { getOrThrow } from "@earendil-works/pi-durable/env";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Connection } from "../src/connection.ts";
import { LocalExecutionEnv } from "../src/list-files.ts";
import { RemoteExecutionEnv } from "../src/remote-env.ts";
import {
acceptHostKey,
connectSsh,
forgetHostKey,
HostKeyChangedError,
HostKeyUnknownError,
type SshTarget,
scanHostKey,
sshConnection,
} from "../src/ssh.ts";
import { daemon } from "./daemon.ts";
/** The deployed daemon is named by its content. */
const deployedName = `pi-env-${createHash("sha256").update(readFileSync(daemon)).digest("hex").slice(0, 32)}`;
const context = BACKGROUND_CONTEXT;
function which(program: string): string | undefined {
try {
return execFileSync("sh", ["-c", `command -v ${program}`], { encoding: "utf8" }).trim() || undefined;
} catch {
return undefined;
}
}
const sshd =
process.platform === "win32"
? undefined
: (which("sshd") ?? (existsSync("/usr/sbin/sshd") ? "/usr/sbin/sshd" : undefined));
async function freePort(): Promise<number> {
return new Promise((resolve) => {
const server = createServer().listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
server.close(() => resolve(port));
});
});
}
async function waitForPort(port: number): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
const open = await new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.once("connect", () => resolve(true)).once("error", () => resolve(false));
socket.connect(port, "127.0.0.1");
}).finally(() => undefined);
if (open) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("sshd did not start");
}
// A disposable sshd on localhost with its own host key, client key and home directory.
describe.skipIf(sshd === undefined)("SSH bootstrap", () => {
const root = mkdtempSync(join(tmpdir(), "pi-env-ssh-"));
const home = join(root, "home");
let server: ChildProcess | undefined;
let target: SshTarget;
beforeAll(async () => {
execFileSync("mkdir", ["-p", home]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "host_key")]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "client_key")]);
writeFileSync(join(root, "authorized_keys"), readFileSync(join(root, "client_key.pub")));
const port = await freePort();
writeFileSync(
join(root, "sshd_config"),
[
`Port ${port}`,
"ListenAddress 127.0.0.1",
`HostKey ${join(root, "host_key")}`,
`AuthorizedKeysFile ${join(root, "authorized_keys")}`,
`PidFile ${join(root, "sshd.pid")}`,
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"StrictModes no",
`SetEnv HOME=${home}`,
"",
].join("\n"),
);
writeFileSync(join(root, "ssh_config"), "");
server = spawn(sshd!, ["-D", "-e", "-f", join(root, "sshd_config")], { stdio: "ignore" });
await waitForPort(port);
target = {
host: "127.0.0.1",
port,
user: userInfo().username,
identityFile: join(root, "client_key"),
knownHostsFile: join(root, "known_hosts"),
hostKeyAlias: "pi-env-test",
configFile: join(root, "ssh_config"),
};
});
afterAll(() => {
server?.kill();
rmSync(root, { recursive: true, force: true });
});
it("refuses an untrusted host, then deploys and runs the daemon once its key is accepted", async () => {
await expect(connectSsh({ ...target, binary: daemon })).rejects.toBeInstanceOf(HostKeyUnknownError);
const scanned = await scanHostKey(target);
const expected = execFileSync("ssh-keygen", ["-lf", join(root, "host_key.pub")], { encoding: "utf8" }).split(
" ",
)[1];
expect(scanned.fingerprints.join("\n")).toContain(expected);
await acceptHostKey(target, scanned.lines);
mkdirSync(join(home, ".pi/mobile/tools"), { recursive: true });
writeFileSync(join(home, ".pi/mobile/tools/pi-env-0123456789abcdef0123456789abcdef"), "old");
const { connection, remote } = await connectSsh({ ...target, binary: daemon });
try {
expect(remote.home).toBe(home);
const deployed = readFileSync(join(home, ".pi/mobile/tools", deployedName));
expect(deployed.equals(readFileSync(daemon))).toBe(true);
// Daemons of other contents are removed.
expect(readdirSync(join(home, ".pi/mobile/tools")).filter((name) => name.startsWith("pi-env-"))).toEqual([
deployedName,
]);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home });
getOrThrow(await env.writeFile("over-ssh.txt", "hello", context));
expect(getOrThrow(await env.readTextFile("over-ssh.txt", context))).toBe("hello");
const output: string[] = [];
const result = getOrThrow(
await env.exec(["sh", "-c", 'printf "%s" "$HOME"'], { onOutput: (text) => output.push(text) }, context),
);
expect(result.exitCode).toBe(0);
expect(output.join("")).toBe(home);
} finally {
connection.close();
}
}, 60_000);
it("reuses a verified daemon and replaces a tampered one", async () => {
const file = join(home, ".pi/mobile/tools", deployedName);
const before = statSync(file).mtimeMs;
(await connectSsh({ ...target, binary: daemon })).connection.close();
expect(statSync(file).mtimeMs).toBe(before);
writeFileSync(file, "tampered");
const { connection } = await connectSsh({ ...target, binary: daemon });
try {
expect(readFileSync(file).equals(readFileSync(daemon))).toBe(true);
expect((await connection.info()).home).toBe(home);
} finally {
connection.close();
}
}, 60_000);
it("verifies the daemon again before starting it after a lost connection", async () => {
const { connection } = await connectSsh({ ...target, binary: daemon });
const file = join(home, ".pi/mobile/tools", deployedName);
try {
const { pid } = await connection.info();
process.kill(pid, "SIGKILL");
await new Promise((done) => setTimeout(done, 300));
// Linux refuses to write a running program's file.
writeFileSync(file, "tampered");
// The next start redeploys the verified binary instead of running whatever is there.
expect((await connection.info()).pid).not.toBe(pid);
expect(readFileSync(file).equals(readFileSync(daemon))).toBe(true);
} finally {
connection.close();
}
}, 60_000);
it("lists files over SSH as the local environment lists them", async () => {
const tree = join(home, "tree");
for (const file of ["a/b.txt", "a/c/d.txt", ".hidden", "out/x.js", "skip/y.txt", ".git/HEAD"]) {
mkdirSync(join(tree, file, ".."), { recursive: true });
writeFileSync(join(tree, file), "");
}
mkdirSync(join(tree, "empty"));
writeFileSync(join(tree, ".gitignore"), "out/\n");
const { connection } = await connectSsh({ ...target, binary: daemon });
const local = new Connection({ command: [daemon] });
try {
const remote = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home });
const listing = getOrThrow(await remote.listFiles("tree", { exclude: ["skip"] }, context));
expect(listing).toEqual({
entries: [
{ path: ".gitignore", kind: "file" },
{ path: ".hidden", kind: "file" },
{ path: "a", kind: "directory" },
{ path: "a/b.txt", kind: "file" },
{ path: "a/c", kind: "directory" },
{ path: "a/c/d.txt", kind: "file" },
{ path: "empty", kind: "directory" },
],
truncated: false,
});
const env = new LocalExecutionEnv({ connection: local, cwd: home });
expect(getOrThrow(await env.listFiles("tree", { exclude: ["skip"] }, context))).toEqual(listing);
} finally {
connection.close();
local.close();
}
}, 60_000);
it("starts the daemon through the login shell only when asked", async () => {
// The login shell is the account's shell: sh and bash read .profile, zsh (the macOS default) reads .zprofile.
writeFileSync(join(home, ".profile"), "export PI_ENV_LOGIN=yes\n");
writeFileSync(join(home, ".zprofile"), "export PI_ENV_LOGIN=yes\n");
const login = async (loginShell: boolean) => {
const { connection } = await connectSsh({ ...target, binary: daemon, loginShell });
try {
const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home });
const output: string[] = [];
getOrThrow(
await env.exec(
["sh", "-c", 'printf "%s" "$PI_ENV_LOGIN"'],
{ onOutput: (text) => output.push(text) },
context,
),
);
return output.join("");
} finally {
connection.close();
}
};
expect(await login(false)).toBe("");
expect(await login(true)).toBe("yes");
}, 60_000);
it("connects lazily and reports a failed start as the error of the operation", async () => {
const lazyTarget = { ...target, knownHostsFile: join(root, "lazy_known_hosts") };
const { connection, remote } = sshConnection({ ...lazyTarget, binary: daemon });
try {
expect(remote()).toBeUndefined();
const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home });
// Nothing is trusted yet: each operation fails with the reason and tries again.
const read = await env.readTextFile("missing.txt", context);
expect(read.ok ? "ok" : read.error.message).toMatch(/not trusted/);
const command = await env.exec(["sh", "-c", "exit 0"], undefined, context);
expect(command.ok ? "ok" : command.error.code).toBe("spawn_error");
await acceptHostKey(lazyTarget, (await scanHostKey(lazyTarget)).lines);
const accepted = await env.exec(["sh", "-c", "exit 0"], undefined, context);
expect(accepted.ok ? accepted.value.exitCode : accepted.error.message).toBe(0);
expect(remote()?.home).toBe(home);
} finally {
connection.close();
}
}, 60_000);
it("accepts only host keys for the alias and never replaces a trusted key silently", async () => {
const knownHosts = join(root, "accept_known_hosts");
const scoped = { ...target, knownHostsFile: knownHosts };
const key = (name: string) => {
const file = join(root, name);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", file]);
return readFileSync(`${file}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
};
const first = key("accept_first");
const second = key("accept_second");
await expect(acceptHostKey(scoped, [`other-alias ${first}`])).rejects.toThrow();
await expect(acceptHostKey(scoped, [`@cert-authority pi-env-test ${first}`])).rejects.toThrow();
await Promise.all([
acceptHostKey(scoped, [`pi-env-test ${first}`]),
acceptHostKey(scoped, [`pi-env-test ${first}`]),
]);
expect(readFileSync(knownHosts, "utf8")).toBe(`pi-env-test ${first}\n`);
await expect(acceptHostKey(scoped, [`pi-env-test ${second}`])).rejects.toBeInstanceOf(HostKeyChangedError);
await forgetHostKey(scoped);
await acceptHostKey(scoped, [`pi-env-test ${second}`]);
expect(readFileSync(knownHosts, "utf8")).toBe(`pi-env-test ${second}\n`);
});
it("refuses a changed host key", async () => {
const other = join(root, "other_key");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", other]);
const key = readFileSync(`${other}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
const knownHosts = join(root, "changed_known_hosts");
writeFileSync(knownHosts, `pi-env-test ${key}\n`);
await expect(connectSsh({ ...target, knownHostsFile: knownHosts, binary: daemon })).rejects.toBeInstanceOf(
HostKeyChangedError,
);
}, 60_000);
});