Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions scripts/devServerAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";

const REPO_ROOT = path.resolve(import.meta.dir, "..");

// make claims the default socket folder at parse time, in `$XDG_RUNTIME_DIR` or /tmp. A dry run must
// not leave one on the test host, so every make call here gets a runtime dir of its own.
//
// The base is under /tmp, not os.tmpdir(): macOS's os.tmpdir() is a long /var/folders/... path, and
// `<base>/xum-dev-<uid>/b3111-192.168.1.5.sock` must fit the 103 bytes of a macOS socket path.
const SHORT_TMP = process.platform === "win32" ? os.tmpdir() : "/tmp";
const RUNTIME_BASE = fsSync.mkdtempSync(path.join(SHORT_TMP, "xum-make-base-"));
afterAll(() => fsSync.rmSync(RUNTIME_BASE, { recursive: true, force: true }));

interface DryRun {
/** XUM_BACKEND_SOCKET as make exports it to the recipe's processes (env, not secret). */
socket: string | undefined;
Expand All @@ -23,7 +33,12 @@ async function dryRun(
): Promise<DryRun> {
const proc = Bun.spawn(["make", "-pn", ...makeFlags, goal], {
cwd: REPO_ROOT,
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", ...env },
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
XDG_RUNTIME_DIR: RUNTIME_BASE,
...env,
},
stdout: "pipe",
stderr: "pipe",
});
Expand Down Expand Up @@ -250,7 +265,7 @@ describe.skipIf(process.platform === "win32")("make dev-server backend socket",

test("the backend listens on the exported path, and the path fits sun_path", async () => {
const socket = exportedSocket(socketRun);
expect(socket.startsWith("/")).toBe(true);
expect(socket.startsWith(`${RUNTIME_BASE}${path.sep}`)).toBe(true);
expect(socket).toMatch(/b3111-127\.0\.0\.1\.sock$/);
expect(Buffer.byteLength(socket)).toBeLessThanOrEqual(100);
expect(await backendSocketArgument(socketRun)).toBe(socket);
Expand Down Expand Up @@ -296,7 +311,7 @@ describe.skipIf(process.platform === "win32")("make dev-server backend socket",
let runtimeDir: string;

beforeEach(async () => {
runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "xum-make-test-"));
runtimeDir = await fs.mkdtemp(path.join(SHORT_TMP, "xum-make-test-"));
});

afterEach(async () => {
Expand Down Expand Up @@ -344,26 +359,44 @@ describe.skipIf(process.platform === "win32")("make dev-server backend socket",
expect(await backendSocketArgument(run)).toBe(socket);
});

test("make stops with a message when the parent cannot be trusted", async () => {
const shared = path.join(runtimeDir, "shared");
await fs.mkdir(shared);
await fs.chmod(shared, 0o777);
/** `make -pn dev-server` that is expected to stop: its exit code and stderr. */
async function makeStops(xdgRuntimeDir: string) {
const proc = Bun.spawn(["make", "-pn", "dev-server"], {
cwd: REPO_ROOT,
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
XDG_RUNTIME_DIR: shared,
XDG_RUNTIME_DIR: xdgRuntimeDir,
},
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
return { exitCode, stderr };
}

test("make stops with a message when the parent cannot be trusted", async () => {
const shared = path.join(runtimeDir, "shared");
await fs.mkdir(shared);
await fs.chmod(shared, 0o777);
const { exitCode, stderr } = await makeStops(shared);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("XUM_BACKEND_SOCKET");
expect(stderr).toContain("XDG_RUNTIME_DIR");
expect(await fs.readdir(shared)).toEqual([]);
});

// The backend refuses a path that does not fit sun_path, so a folder made for it would be an
// empty folder for nothing: make stops first, and creates nothing.
test("make stops with a message when the default path is too long, and creates nothing", async () => {
const deep = path.join(runtimeDir, "d".repeat(100));
await fs.mkdir(deep, { mode: 0o700 });
const { exitCode, stderr } = await makeStops(deep);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/too long/);
expect(stderr).toContain("XUM_BACKEND_SOCKET");
expect(await fs.readdir(deep)).toEqual([]);
});
});

test("Windows ignores an ambient XUM_BACKEND_SOCKET", () => {
Expand Down
16 changes: 13 additions & 3 deletions src/node/utils/privateUnixSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { acquireProcessFileLock } from "@/node/utils/concurrency/fileLock";
import { withPrivateUnixSocketPath } from "./privateUnixSocket";
import { assertLockPathUsable, withPrivateUnixSocketPath } from "./privateUnixSocket";
import { MAX_UNIX_SOCKET_PATH_BYTES } from "./privateUnixSocketFolder";

const posix = process.platform !== "win32";
Expand Down Expand Up @@ -337,12 +337,22 @@ describe.skipIf(!posix)("withPrivateUnixSocketPath", () => {
expect((await fs.lstat(`${socketPath}.lock`)).isSymbolicLink()).toBe(true);
});

// Called on its own: through withPrivateUnixSocketPath a foreign uid fails the folder check first,
// and only root could make a file of another user inside our private folder. The file here is
// ours, and the expected owner is not.
test("refuses a lock file that another user owns", async () => {
await fs.mkdir(dir, { mode: 0o700 });
await fs.writeFile(`${socketPath}.lock`, "someone else", { mode: 0o600 });
await expect(prepare(socketPath, process.getuid!() + 1)).rejects.toThrow(
const lockPath = `${socketPath}.lock`;
await fs.writeFile(lockPath, "someone else", { mode: 0o600 });
await expect(assertLockPathUsable(lockPath, process.getuid!() + 1)).rejects.toThrow(
/owned by another user/
);
await assertLockPathUsable(lockPath, process.getuid!());
});

test("accepts a missing lock file", async () => {
await fs.mkdir(dir, { mode: 0o700 });
await assertLockPathUsable(`${socketPath}.lock`, process.getuid!());
});

test("refuses a lock path that is a folder", async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/node/utils/privateUnixSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function removeStaleSocket(socketPath: string, ownerUid: number): Promise<
}

/** The lock file must be a regular file of ours: a symlink would send the lock somewhere else. */
async function assertLockPathUsable(lockPath: string, ownerUid: number): Promise<void> {
export async function assertLockPathUsable(lockPath: string, ownerUid: number): Promise<void> {
let entry;
try {
entry = await fs.lstat(lockPath);
Expand Down
14 changes: 14 additions & 0 deletions src/node/utils/privateUnixSocketFolder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ describe.skipIf(!posix)("chooseDevSocketPath", () => {
expect(stat.mode & 0o777).toBe(0o700);
});

test("a default path that cannot fit is refused before the folder is created", async () => {
const error = await chooseDevSocketPath(
preferred,
"s".repeat(MAX_UNIX_SOCKET_PATH_BYTES),
uid
).then(
() => null,
(e: unknown) => e
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/too long/);
expect(await fs.readdir(base)).toEqual([]);
});

test("a private folder of ours is the answer", async () => {
await fs.mkdir(preferred, { mode: 0o700 });
const choice = await chooseDevSocketPath(preferred, "b1.sock", uid);
Expand Down
4 changes: 4 additions & 0 deletions src/node/utils/privateUnixSocketFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const MKDTEMP_SUFFIX = ".XXXXXX";
* caller to tell the user. The Vite hop never falls back to TCP: the squat window that the socket
* closes would open again.
*
* Throws, before it creates anything, when the default path does not fit a socket path.
* Throws when a parent of the folder is unsafe: a fresh folder next to it would be just as open to
* another account, so only the user can fix it (XUM_BACKEND_SOCKET or XDG_RUNTIME_DIR). It also
* throws, before it creates the fresh folder, when the longer fallback path would not fit.
Expand All @@ -140,6 +141,9 @@ export async function chooseDevSocketPath(
ownerUid: number
): Promise<DevSocketChoice> {
const preferred = path.join(folder, fileName);
// Before anything is created: the backend refuses a path that does not fit, and the folder made
// for it would stay empty.
assertSocketPathFits(preferred);
await assertParentsTrusted(folder, ownerUid);
try {
await fs.mkdir(folder, { mode: 0o700 });
Expand Down
Loading