mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
1147 lines
39 KiB
TypeScript
1147 lines
39 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import http from "node:http";
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from "vitest";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
type PublishFs = {
|
|
rename: (from: string, to: string) => Promise<void>;
|
|
link: (from: string, to: string) => Promise<void>;
|
|
copyFileExcl: (from: string, to: string) => Promise<void>;
|
|
rm: (target: string) => Promise<void>;
|
|
lstat: (target: string) => Promise<fs.Stats>;
|
|
};
|
|
|
|
const localFiles = require("../../../../electron/local-files.cjs") as {
|
|
assertWithinRoot: (
|
|
rootPath: string,
|
|
candidate: string,
|
|
pathImpl?: typeof path,
|
|
) => string;
|
|
publishDownload: (
|
|
partialPath: string,
|
|
absDest: string,
|
|
overwrite: boolean,
|
|
transferId: string,
|
|
io?: PublishFs,
|
|
) => Promise<void>;
|
|
defaultPublishFs: PublishFs;
|
|
IPC: Record<string, string>;
|
|
TRANSFER_ROUTES: Record<string, string>;
|
|
createTargetResolver: (deps: {
|
|
localBaseUrl?: string;
|
|
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
|
getRemoteSyncJwt?: () => string | null;
|
|
}) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
};
|
|
createLocalFileHandlers: (deps: {
|
|
net: unknown;
|
|
shell: unknown;
|
|
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
|
getRemoteSyncJwt?: () => string | null;
|
|
localBaseUrl?: string;
|
|
publishFs?: PublishFs;
|
|
}) => Record<
|
|
string,
|
|
(
|
|
event: unknown,
|
|
...args: unknown[]
|
|
) => Promise<
|
|
{ success: boolean; error?: string; code?: string } & Record<
|
|
string,
|
|
unknown
|
|
>
|
|
>
|
|
>;
|
|
};
|
|
|
|
// Minimal stand-in for Electron's net.request built on Node http, exposing
|
|
// the subset of the ClientRequest API local-files.cjs uses.
|
|
/** Header names the bridge tried to set on any request, lower-cased. */
|
|
const headersSetByBridge: string[] = [];
|
|
|
|
class FakeClientRequest extends EventEmitter {
|
|
private req: http.ClientRequest;
|
|
chunkedEncoding = false;
|
|
constructor(opts: { method: string; url: string }) {
|
|
super();
|
|
const u = new URL(opts.url);
|
|
this.req = http.request({
|
|
method: opts.method,
|
|
hostname: u.hostname,
|
|
port: u.port,
|
|
path: u.pathname,
|
|
});
|
|
this.req.on("response", (res) => this.emit("response", res));
|
|
this.req.on("error", (e) => this.emit("error", e));
|
|
}
|
|
setHeader(k: string, v: string) {
|
|
headersSetByBridge.push(k.toLowerCase());
|
|
this.req.setHeader(k, v);
|
|
}
|
|
write(chunk: Buffer | string, cb?: () => void) {
|
|
return this.req.write(chunk, cb);
|
|
}
|
|
end(chunk?: Buffer | string) {
|
|
this.req.end(chunk);
|
|
}
|
|
abort() {
|
|
this.req.destroy();
|
|
this.emit("abort");
|
|
}
|
|
}
|
|
|
|
const fakeNet = {
|
|
request: (opts: { method: string; url: string }) =>
|
|
new FakeClientRequest(opts),
|
|
};
|
|
|
|
const fakeEvent = {
|
|
sender: { session: null, isDestroyed: () => false, send: () => {} },
|
|
};
|
|
|
|
// A backend stand-in: records what it receives and serves a fixed payload on
|
|
// the download route (optionally slowly, to exercise concurrency).
|
|
function startBackend(
|
|
payload: Buffer,
|
|
opts: { delayMs?: number; failAll?: boolean } = {},
|
|
) {
|
|
const seen: Array<{ url: string; headers: http.IncomingHttpHeaders }> = [];
|
|
const server = http.createServer((req, res) => {
|
|
seen.push({ url: req.url || "", headers: req.headers });
|
|
let body = "";
|
|
req.on("data", (d) => (body += d));
|
|
req.on("end", () => {
|
|
if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) {
|
|
// Headers go out immediately so the client opens its temp file;
|
|
// the body may be delayed to keep the transfer in flight.
|
|
res.writeHead(200, { "Content-Length": payload.length });
|
|
res.flushHeaders();
|
|
const send = () => res.end(payload);
|
|
if (opts.delayMs) setTimeout(send, opts.delayMs);
|
|
else send();
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: `no route ${req.url}` }));
|
|
});
|
|
});
|
|
return new Promise<{
|
|
url: string;
|
|
seen: typeof seen;
|
|
close: () => Promise<void>;
|
|
}>((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address() as { port: number };
|
|
resolve({
|
|
url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`,
|
|
seen,
|
|
close: () => new Promise((r) => server.close(() => r())),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("local-files transfer target resolution", () => {
|
|
const resolve = localFiles.createTargetResolver({
|
|
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
|
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
|
|
getRemoteSyncJwt: () => "remote-jwt",
|
|
});
|
|
|
|
it("only reaches the two file-manager streaming routes on the local backend", () => {
|
|
expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({
|
|
url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
|
|
headers: { "X-Electron-App": "true" },
|
|
});
|
|
expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe(
|
|
"http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream",
|
|
);
|
|
});
|
|
|
|
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
|
|
const target = resolve({ origin: "remote", route: "downloadFileStream" });
|
|
expect(target.url).toBe(
|
|
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
|
|
);
|
|
expect(target.headers.Authorization).toBe("Bearer remote-jwt");
|
|
});
|
|
|
|
it("refuses unknown origins, routes, and off-origin URLs smuggled as either", () => {
|
|
expect(() =>
|
|
resolve({ origin: "https://evil.example", route: "uploadFileStream" }),
|
|
).toThrow(/Unknown transfer origin/);
|
|
expect(() => resolve({ origin: "local", route: "/../admin" })).toThrow(
|
|
/Unknown transfer route/,
|
|
);
|
|
expect(() =>
|
|
resolve({ origin: "local", route: "http://evil.example/x" }),
|
|
).toThrow(/Unknown transfer route/);
|
|
expect(() => resolve({})).toThrow(/Unknown transfer route/);
|
|
});
|
|
|
|
it("refuses a remote origin when no sync server is configured or it is not http(s)", () => {
|
|
const unconfigured = localFiles.createTargetResolver({
|
|
getRemoteSyncConfig: () => null,
|
|
});
|
|
expect(() =>
|
|
unconfigured({ origin: "remote", route: "uploadFileStream" }),
|
|
).toThrow(/not configured/);
|
|
const bogus = localFiles.createTargetResolver({
|
|
getRemoteSyncConfig: () => ({ serverUrl: "file:///etc/passwd" }),
|
|
});
|
|
expect(() =>
|
|
bogus({ origin: "remote", route: "uploadFileStream" }),
|
|
).toThrow(/must use http or https/);
|
|
});
|
|
|
|
it("accepts only a well-formed device id and never arbitrary headers", () => {
|
|
expect(
|
|
resolve({
|
|
origin: "local",
|
|
route: "uploadFileStream",
|
|
deviceId: "dev_1.2:3-x",
|
|
}).headers["X-Termix-Device-ID"],
|
|
).toBe("dev_1.2:3-x");
|
|
expect(() =>
|
|
resolve({
|
|
origin: "local",
|
|
route: "uploadFileStream",
|
|
deviceId: "x\r\nHost: evil",
|
|
}),
|
|
).toThrow(/Invalid device id/);
|
|
const headers = resolve({
|
|
origin: "local",
|
|
route: "uploadFileStream",
|
|
}).headers;
|
|
expect(Object.keys(headers)).toEqual(["X-Electron-App"]);
|
|
});
|
|
|
|
it("sends the renderer's local token as a Bearer header so transfers outlive the jwt cookie", () => {
|
|
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln-_X";
|
|
const target = resolve({
|
|
origin: "local",
|
|
route: "uploadFileStream",
|
|
authToken: jwt,
|
|
});
|
|
expect(target.headers.Authorization).toBe(`Bearer ${jwt}`);
|
|
expect(target.url).toBe(
|
|
"http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
|
|
);
|
|
// Absent or empty: no header at all (the session cookie is the fallback).
|
|
for (const authToken of [undefined, null, ""]) {
|
|
expect(
|
|
resolve({ origin: "local", route: "downloadFileStream", authToken })
|
|
.headers.Authorization,
|
|
).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("refuses anything but a compact JWT as the local token", () => {
|
|
for (const authToken of [
|
|
"tmx_apikey",
|
|
"a.b",
|
|
"x\r\nX-Injected: 1",
|
|
"Bearer abc.def.ghi",
|
|
{ toString: () => "a.b.c" },
|
|
"a.b.".padEnd(9000, "c"),
|
|
]) {
|
|
expect(() =>
|
|
resolve({ origin: "local", route: "uploadFileStream", authToken }),
|
|
).toThrow(/Invalid auth token/);
|
|
}
|
|
});
|
|
|
|
it("never lets the renderer's token replace the main process's Remote Sync JWT", () => {
|
|
const target = resolve({
|
|
origin: "remote",
|
|
route: "uploadFileStream",
|
|
authToken: "aaa.bbb.ccc",
|
|
});
|
|
expect(target.headers.Authorization).toBe("Bearer remote-jwt");
|
|
});
|
|
});
|
|
|
|
describe("local-files download boundary", () => {
|
|
let root: string;
|
|
const payload = crypto.randomBytes(64 * 1024 + 7);
|
|
let backend: Awaited<ReturnType<typeof startBackend>>;
|
|
let handlers: ReturnType<typeof localFiles.createLocalFileHandlers>;
|
|
|
|
beforeAll(async () => {
|
|
backend = await startBackend(payload);
|
|
handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: backend.url,
|
|
getRemoteSyncConfig: () => null,
|
|
getRemoteSyncJwt: () => null,
|
|
});
|
|
});
|
|
afterAll(() => backend.close());
|
|
beforeEach(async () => {
|
|
root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-local-files-"));
|
|
});
|
|
afterEach(() => fsp.rm(root, { recursive: true, force: true }));
|
|
|
|
const download = (
|
|
dest: string,
|
|
extra: Record<string, unknown> = {},
|
|
transferId = `t-${Math.random().toString(36).slice(2)}`,
|
|
) =>
|
|
handlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId,
|
|
origin: "local",
|
|
body: { sessionId: "1", path: "/remote/file.bin" },
|
|
destPath: dest,
|
|
rootPath: root,
|
|
...extra,
|
|
});
|
|
|
|
it("rejects existing linked parents for downloads and directory creation", async () => {
|
|
const selected = path.join(root, "selected");
|
|
const outside = path.join(root, "outside");
|
|
await fsp.mkdir(selected);
|
|
await fsp.mkdir(outside);
|
|
await fsp.symlink(outside, path.join(selected, "linked"), "junction");
|
|
const result = await download(path.join(selected, "linked", "file.bin"), {
|
|
rootPath: selected,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
const mkdir = await handlers[localFiles.IPC.ENSURE_DIR](
|
|
fakeEvent,
|
|
path.join(selected, "linked", "new"),
|
|
selected,
|
|
);
|
|
expect(mkdir.success).toBe(false);
|
|
expect(await fsp.readdir(outside)).toEqual([]);
|
|
});
|
|
|
|
it("allows an explicitly selected linked root", async () => {
|
|
const actual = path.join(root, "actual");
|
|
const selected = path.join(root, "selected");
|
|
await fsp.mkdir(actual);
|
|
await fsp.symlink(actual, selected, "junction");
|
|
const result = await download(path.join(selected, "nested", "file.bin"), {
|
|
rootPath: selected,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(await fsp.readFile(path.join(actual, "nested", "file.bin"))).toEqual(
|
|
payload,
|
|
);
|
|
});
|
|
|
|
it("ignores renderer-supplied url/headers and talks to the resolved backend only", async () => {
|
|
const dest = path.join(root, "a.bin");
|
|
const result = await download(dest, {
|
|
url: "http://evil.example/steal",
|
|
headers: { Authorization: "Bearer leaked", Cookie: "jwt=leaked" },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
const last = backend.seen[backend.seen.length - 1];
|
|
expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream");
|
|
expect(last.headers.authorization).toBeUndefined();
|
|
expect(last.headers.cookie).toBeUndefined();
|
|
expect(last.headers["x-electron-app"]).toBe("true");
|
|
expect((await fsp.readFile(dest)).equals(payload)).toBe(true);
|
|
});
|
|
|
|
it("never sets headers Electron's net module forbids (the request would fail with ERR_INVALID_ARGUMENT)", async () => {
|
|
headersSetByBridge.length = 0;
|
|
const result = await download(path.join(root, "b.bin"));
|
|
expect(result.success).toBe(true);
|
|
// Electron computes Content-Length from the buffered body itself and
|
|
// rejects requests that set it (or any other restricted header) by hand.
|
|
const restricted = [
|
|
"content-length",
|
|
"host",
|
|
"trailer",
|
|
"te",
|
|
"upgrade",
|
|
"cookie2",
|
|
"keep-alive",
|
|
"transfer-encoding",
|
|
];
|
|
expect(headersSetByBridge.filter((h) => restricted.includes(h))).toEqual(
|
|
[],
|
|
);
|
|
// The JSON body still reached the backend intact.
|
|
const last = backend.seen[backend.seen.length - 1];
|
|
expect(last.headers["content-type"]).toBe("application/json");
|
|
});
|
|
|
|
it("refuses to replace an existing file by default and leaves it untouched", async () => {
|
|
const dest = path.join(root, "keep.bin");
|
|
await fsp.writeFile(dest, "original");
|
|
const requestsBefore = backend.seen.length;
|
|
|
|
const result = await download(dest);
|
|
expect(result.success).toBe(false);
|
|
expect(result.code).toBe("EEXIST");
|
|
expect(await fsp.readFile(dest, "utf8")).toBe("original");
|
|
// Refused before any network traffic.
|
|
expect(backend.seen.length).toBe(requestsBefore);
|
|
expect(await fsp.readdir(root)).toEqual(["keep.bin"]);
|
|
});
|
|
|
|
it("replaces an existing file only when overwrite is explicitly requested", async () => {
|
|
const dest = path.join(root, "replace.bin");
|
|
await fsp.writeFile(dest, "original");
|
|
const result = await download(dest, { overwrite: true });
|
|
expect(result.success).toBe(true);
|
|
expect((await fsp.readFile(dest)).equals(payload)).toBe(true);
|
|
expect(await fsp.readdir(root)).toEqual(["replace.bin"]);
|
|
});
|
|
|
|
it("refuses to publish over a file that appeared while the download was running", async () => {
|
|
const slow = await startBackend(payload, { delayMs: 300 });
|
|
const slowHandlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: slow.url,
|
|
});
|
|
try {
|
|
const dest = path.join(root, "race.bin");
|
|
const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "race-1",
|
|
origin: "local",
|
|
body: {},
|
|
destPath: dest,
|
|
rootPath: root,
|
|
});
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
await fsp.writeFile(dest, "someone else wrote this");
|
|
const result = await pending;
|
|
expect(result.success).toBe(false);
|
|
expect(result.code).toBe("EEXIST");
|
|
expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this");
|
|
expect(await fsp.readdir(root)).toEqual(["race.bin"]);
|
|
} finally {
|
|
await slow.close();
|
|
}
|
|
});
|
|
|
|
it("rejects a destination parent replaced by a link during download", async () => {
|
|
const selected = path.join(root, "selected");
|
|
const nested = path.join(selected, "nested");
|
|
const outside = path.join(root, "outside");
|
|
await fsp.mkdir(nested, { recursive: true });
|
|
await fsp.mkdir(outside);
|
|
const slow = await startBackend(payload, { delayMs: 300 });
|
|
const slowHandlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: slow.url,
|
|
});
|
|
try {
|
|
const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "parent-race",
|
|
origin: "local",
|
|
body: {},
|
|
rootPath: selected,
|
|
destPath: path.join(nested, "file.bin"),
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
await fsp.rename(nested, path.join(selected, "original"));
|
|
await fsp.symlink(outside, nested, "junction");
|
|
const result = await pending;
|
|
expect(result.success).toBe(false);
|
|
expect(await fsp.readdir(outside)).toEqual([]);
|
|
expect((await fsp.readdir(selected)).sort()).toEqual([
|
|
"nested",
|
|
"original",
|
|
]);
|
|
} finally {
|
|
await slow.close();
|
|
}
|
|
});
|
|
|
|
it("uses a transfer-unique temp file and rejects a concurrent download to the same destination", async () => {
|
|
const slow = await startBackend(payload, { delayMs: 300 });
|
|
const slowHandlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: slow.url,
|
|
});
|
|
try {
|
|
const dest = path.join(root, "same.bin");
|
|
const first = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "first",
|
|
origin: "local",
|
|
body: {},
|
|
destPath: dest,
|
|
rootPath: root,
|
|
});
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
const second = await slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "second",
|
|
origin: "local",
|
|
body: {},
|
|
destPath: dest,
|
|
rootPath: root,
|
|
});
|
|
expect(second.success).toBe(false);
|
|
expect(second.code).toBe("EBUSY");
|
|
|
|
// While the first is in flight its partial carries the transfer id.
|
|
const partials = (await fsp.readdir(root)).filter((n) =>
|
|
n.endsWith(".termix-part"),
|
|
);
|
|
expect(partials).toEqual(["same.bin.first.termix-part"]);
|
|
|
|
const result = await first;
|
|
expect(result.success).toBe(true);
|
|
expect((await fsp.readFile(dest)).equals(payload)).toBe(true);
|
|
expect(await fsp.readdir(root)).toEqual(["same.bin"]);
|
|
} finally {
|
|
await slow.close();
|
|
}
|
|
});
|
|
|
|
it("runs many downloads at once, cancels one without disturbing the others, and leaves no partials", async () => {
|
|
const slow = await startBackend(payload, { delayMs: 250 });
|
|
const slowHandlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: slow.url,
|
|
});
|
|
try {
|
|
const dests = Array.from({ length: 5 }, (_, i) =>
|
|
path.join(root, "many", `part${i}`, `file${i}.bin`),
|
|
);
|
|
const pending = dests.map((dest, i) =>
|
|
slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: `many-${i}`,
|
|
origin: "local",
|
|
body: { sessionId: "1", path: "/remote/file.bin" },
|
|
destPath: dest,
|
|
rootPath: root,
|
|
}),
|
|
);
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
const cancelled = await slowHandlers[localFiles.IPC.CANCEL](
|
|
fakeEvent,
|
|
"many-2",
|
|
);
|
|
expect(cancelled.success).toBe(true);
|
|
const results = await Promise.all(pending);
|
|
results.forEach((r, i) => {
|
|
if (i === 2) expect(r.success).toBe(false);
|
|
else expect(r.success).toBe(true);
|
|
});
|
|
// Partials live in the selected root while in flight; none may remain.
|
|
expect(await fsp.readdir(root)).toEqual(["many"]);
|
|
for (const [i, dest] of dests.entries()) {
|
|
if (i === 2) {
|
|
expect(await fsp.readdir(path.dirname(dest))).toEqual([]);
|
|
} else {
|
|
expect((await fsp.readFile(dest)).equals(payload)).toBe(true);
|
|
expect(await fsp.readdir(path.dirname(dest))).toEqual([
|
|
`file${i}.bin`,
|
|
]);
|
|
}
|
|
}
|
|
} finally {
|
|
slow.close();
|
|
}
|
|
});
|
|
|
|
it("allows two downloads of different files to run side by side", async () => {
|
|
const [a, b] = await Promise.all([
|
|
download(path.join(root, "one.bin")),
|
|
download(path.join(root, "two.bin")),
|
|
]);
|
|
expect(a.success && b.success).toBe(true);
|
|
expect((await fsp.readdir(root)).sort()).toEqual(["one.bin", "two.bin"]);
|
|
});
|
|
|
|
it("cleans up its partial file and never creates the destination on a backend error", async () => {
|
|
const failingBackend = await startBackend(payload, { failAll: true });
|
|
const failing = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: failingBackend.url,
|
|
});
|
|
try {
|
|
const bad = await failing[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "err",
|
|
origin: "local",
|
|
body: {},
|
|
destPath: path.join(root, "never.bin"),
|
|
rootPath: root,
|
|
});
|
|
expect(bad.success).toBe(false);
|
|
expect(bad.error).toMatch(/no route/);
|
|
expect(fs.existsSync(path.join(root, "never.bin"))).toBe(false);
|
|
expect(await fsp.readdir(root)).toEqual([]);
|
|
} finally {
|
|
await failingBackend.close();
|
|
}
|
|
});
|
|
|
|
it("reports which destinations already exist for the collision prompt", async () => {
|
|
const present = path.join(root, "present.txt");
|
|
await fsp.writeFile(present, "x");
|
|
const result = await handlers[localFiles.IPC.EXISTS](fakeEvent, [
|
|
present,
|
|
path.join(root, "absent.txt"),
|
|
]);
|
|
expect(result.success).toBe(true);
|
|
expect(result.existing).toEqual([present]);
|
|
});
|
|
});
|
|
|
|
// Simulates the Windows rename contract on top of the real filesystem:
|
|
// rename() onto a name that already exists fails instead of replacing it.
|
|
// Every rename is recorded so a test can prove the swap never relied on
|
|
// rename-over-existing.
|
|
function windowsLikeFs(
|
|
overrides: Partial<PublishFs> = {},
|
|
): PublishFs & { renames: Array<[string, string]> } {
|
|
const base = localFiles.defaultPublishFs;
|
|
const renames: Array<[string, string]> = [];
|
|
return {
|
|
...base,
|
|
renames,
|
|
rename: async (from, to) => {
|
|
renames.push([from, to]);
|
|
if (fs.existsSync(to)) {
|
|
throw Object.assign(new Error(`EEXIST: file already exists, rename`), {
|
|
code: "EEXIST",
|
|
});
|
|
}
|
|
await base.rename(from, to);
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("download destination containment", () => {
|
|
const win = path.win32;
|
|
const root = "C:\\Downloads\\selected";
|
|
|
|
it("accepts ordinary nested destinations on Windows", () => {
|
|
expect(
|
|
localFiles.assertWithinRoot(root, "C:\\Downloads\\selected\\a.txt", win),
|
|
).toBe("C:\\Downloads\\selected\\a.txt");
|
|
expect(
|
|
localFiles.assertWithinRoot(
|
|
root,
|
|
"C:\\Downloads\\selected\\docs\\2026\\report.pdf",
|
|
win,
|
|
),
|
|
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
|
|
// case-insensitive drive/dir comparison, like the filesystem
|
|
expect(
|
|
localFiles.assertWithinRoot(root, "c:\\downloads\\SELECTED\\b.txt", win),
|
|
).toBe("c:\\downloads\\SELECTED\\b.txt");
|
|
});
|
|
|
|
it("rejects backslash traversal that normalises out of the root on Windows", () => {
|
|
// The reviewer's reproduction: selected\..\outside.txt -> Downloads\outside.txt
|
|
expect(() =>
|
|
localFiles.assertWithinRoot(
|
|
root,
|
|
"C:\\Downloads\\selected\\..\\outside.txt",
|
|
win,
|
|
),
|
|
).toThrow(/outside the selected folder/);
|
|
expect(() =>
|
|
localFiles.assertWithinRoot(
|
|
root,
|
|
"C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt",
|
|
win,
|
|
),
|
|
).toThrow(/outside the selected folder/);
|
|
// the root itself is not a valid file destination
|
|
expect(() => localFiles.assertWithinRoot(root, root, win)).toThrow(
|
|
/outside the selected folder/,
|
|
);
|
|
});
|
|
|
|
it("rejects absolute, drive-qualified and UNC destinations on Windows", () => {
|
|
for (const dest of [
|
|
"C:\\Windows\\evil.dll",
|
|
"D:\\Downloads\\selected\\x.txt",
|
|
"\\\\server\\share\\x.txt",
|
|
"C:\\Downloads\\selected-other\\x.txt",
|
|
]) {
|
|
expect(() => localFiles.assertWithinRoot(root, dest, win)).toThrow(
|
|
/outside the selected folder/,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("requires a root and rejects POSIX traversal too", () => {
|
|
expect(() => localFiles.assertWithinRoot("", "/tmp/x", path.posix)).toThrow(
|
|
/download folder is required/,
|
|
);
|
|
expect(() =>
|
|
localFiles.assertWithinRoot(
|
|
"/home/max/dl",
|
|
"/home/max/dl/../x",
|
|
path.posix,
|
|
),
|
|
).toThrow(/outside the selected folder/);
|
|
expect(
|
|
localFiles.assertWithinRoot(
|
|
"/home/max/dl",
|
|
"/home/max/dl/..\\x",
|
|
path.posix,
|
|
),
|
|
).toBe("/home/max/dl/..\\x");
|
|
});
|
|
|
|
it("refuses a download whose destination escapes the root, before any network or disk write", async () => {
|
|
const payload = crypto.randomBytes(1024);
|
|
const backend = await startBackend(payload);
|
|
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-contain-"));
|
|
const selected = path.join(tmp, "selected");
|
|
await fsp.mkdir(selected);
|
|
try {
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: backend.url,
|
|
});
|
|
const requestsBefore = backend.seen.length;
|
|
const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "escape-1",
|
|
origin: "local",
|
|
body: { sessionId: "1", path: "/remote/file.bin" },
|
|
destPath: path.join(selected, "..", "outside.txt"),
|
|
rootPath: selected,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.code).toBe("EINVAL");
|
|
expect(backend.seen.length).toBe(requestsBefore);
|
|
expect(await fsp.readdir(tmp)).toEqual(["selected"]);
|
|
|
|
// and the same for the directory skeleton
|
|
const dir = await handlers[localFiles.IPC.ENSURE_DIR](
|
|
fakeEvent,
|
|
path.join(selected, "..", "escaped-dir"),
|
|
selected,
|
|
);
|
|
expect(dir.success).toBe(false);
|
|
expect(dir.code).toBe("EINVAL");
|
|
expect(await fsp.readdir(tmp)).toEqual(["selected"]);
|
|
|
|
// a missing root is refused as well
|
|
const noRoot = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "escape-2",
|
|
origin: "local",
|
|
body: { sessionId: "1", path: "/remote/file.bin" },
|
|
destPath: path.join(selected, "ok.bin"),
|
|
});
|
|
expect(noRoot.success).toBe(false);
|
|
expect(noRoot.code).toBe("EINVAL");
|
|
expect(await fsp.readdir(selected)).toEqual([]);
|
|
} finally {
|
|
backend.close();
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("local-files replace primitive (Windows-safe overwrite)", () => {
|
|
let root: string;
|
|
beforeEach(async () => {
|
|
root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-replace-"));
|
|
});
|
|
afterEach(() => fsp.rm(root, { recursive: true, force: true }));
|
|
|
|
const seed = async (name: string, existing: string, fresh: Buffer) => {
|
|
const dest = path.join(root, name);
|
|
const partial = `${dest}.t1.termix-part`;
|
|
await fsp.writeFile(dest, existing);
|
|
await fsp.writeFile(partial, fresh);
|
|
return { dest, partial };
|
|
};
|
|
|
|
it("replaces an existing file without renaming onto an occupied name", async () => {
|
|
const fresh = crypto.randomBytes(4096);
|
|
const { dest, partial } = await seed("swap.bin", "original", fresh);
|
|
const io = windowsLikeFs();
|
|
|
|
await localFiles.publishDownload(partial, dest, true, "t1", io);
|
|
|
|
expect((await fsp.readFile(dest)).equals(fresh)).toBe(true);
|
|
expect(await fsp.readdir(root)).toEqual(["swap.bin"]);
|
|
// Every rename targeted a name that was free at the time.
|
|
expect(io.renames.length).toBeGreaterThan(0);
|
|
for (const [, to] of io.renames) {
|
|
expect(to === dest || to.endsWith(".termix-replaced")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("restores the original byte-for-byte when publishing the new contents fails", async () => {
|
|
const fresh = crypto.randomBytes(1024);
|
|
const { dest, partial } = await seed("restore.bin", "original", fresh);
|
|
const denied = () =>
|
|
Promise.reject(
|
|
Object.assign(new Error("EACCES: permission denied"), {
|
|
code: "EACCES",
|
|
}),
|
|
);
|
|
const io = windowsLikeFs({ link: denied, copyFileExcl: denied });
|
|
|
|
await expect(
|
|
localFiles.publishDownload(partial, dest, true, "t1", io),
|
|
).rejects.toMatchObject({ code: "EACCES" });
|
|
|
|
expect(await fsp.readFile(dest, "utf8")).toBe("original");
|
|
// Only the original and the caller-owned partial remain: no aside copy.
|
|
expect((await fsp.readdir(root)).sort()).toEqual(
|
|
["restore.bin", "restore.bin.t1.termix-part"].sort(),
|
|
);
|
|
});
|
|
|
|
it("reports EBUSY and touches nothing when the existing file cannot be moved aside (open on Windows)", async () => {
|
|
const fresh = crypto.randomBytes(1024);
|
|
const { dest, partial } = await seed("locked.bin", "original", fresh);
|
|
const io = windowsLikeFs({
|
|
rename: () =>
|
|
Promise.reject(
|
|
Object.assign(new Error("EPERM: operation not permitted"), {
|
|
code: "EPERM",
|
|
}),
|
|
),
|
|
});
|
|
|
|
await expect(
|
|
localFiles.publishDownload(partial, dest, true, "t1", io),
|
|
).rejects.toMatchObject({ code: "EBUSY" });
|
|
|
|
expect(await fsp.readFile(dest, "utf8")).toBe("original");
|
|
expect((await fsp.readFile(partial)).equals(fresh)).toBe(true);
|
|
});
|
|
|
|
it("refuses to replace a folder with a file", async () => {
|
|
const dest = path.join(root, "folder");
|
|
await fsp.mkdir(dest);
|
|
await fsp.writeFile(path.join(dest, "inner.txt"), "keep");
|
|
const partial = `${dest}.t1.termix-part`;
|
|
await fsp.writeFile(partial, "new");
|
|
|
|
await expect(
|
|
localFiles.publishDownload(partial, dest, true, "t1", windowsLikeFs()),
|
|
).rejects.toMatchObject({ code: "EISDIR" });
|
|
expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe(
|
|
"keep",
|
|
);
|
|
});
|
|
|
|
it("falls back to an exclusive publish when the file to replace has disappeared", async () => {
|
|
const dest = path.join(root, "gone.bin");
|
|
const partial = `${dest}.t1.termix-part`;
|
|
await fsp.writeFile(partial, "new");
|
|
|
|
await localFiles.publishDownload(
|
|
partial,
|
|
dest,
|
|
true,
|
|
"t1",
|
|
windowsLikeFs(),
|
|
);
|
|
expect(await fsp.readFile(dest, "utf8")).toBe("new");
|
|
expect(await fsp.readdir(root)).toEqual(["gone.bin"]);
|
|
});
|
|
|
|
it("works end to end through the download handler on a Windows-like filesystem", async () => {
|
|
const payload = crypto.randomBytes(16 * 1024 + 3);
|
|
const backend = await startBackend(payload);
|
|
try {
|
|
const io = windowsLikeFs();
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: backend.url,
|
|
publishFs: io,
|
|
});
|
|
const dest = path.join(root, "e2e.bin");
|
|
await fsp.writeFile(dest, "original");
|
|
|
|
const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, {
|
|
transferId: "e2e-1",
|
|
origin: "local",
|
|
body: { sessionId: "1", path: "/remote/file.bin" },
|
|
destPath: dest,
|
|
rootPath: root,
|
|
overwrite: true,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect((await fsp.readFile(dest)).equals(payload)).toBe(true);
|
|
expect(await fsp.readdir(root)).toEqual(["e2e.bin"]);
|
|
for (const [, to] of io.renames) {
|
|
expect(to === dest || to.endsWith(".termix-replaced")).toBe(true);
|
|
}
|
|
} finally {
|
|
backend.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("local-files upload boundary", () => {
|
|
it("streams the file as multipart to the resolved backend and ignores renderer url/headers", async () => {
|
|
const Busboy = require("busboy") as (opts: {
|
|
headers: http.IncomingHttpHeaders;
|
|
}) => NodeJS.EventEmitter & NodeJS.WritableStream;
|
|
const received: {
|
|
fields: Record<string, string>;
|
|
fileName?: string;
|
|
hash?: string;
|
|
url?: string;
|
|
headers?: http.IncomingHttpHeaders;
|
|
} = { fields: {} };
|
|
|
|
const server = http.createServer((req, res) => {
|
|
received.url = req.url;
|
|
received.headers = req.headers;
|
|
const bb = Busboy({ headers: req.headers });
|
|
bb.on("field", (name: string, value: string) => {
|
|
received.fields[name] = value;
|
|
});
|
|
bb.on(
|
|
"file",
|
|
(
|
|
_name: string,
|
|
stream: NodeJS.ReadableStream,
|
|
info: { filename: string },
|
|
) => {
|
|
received.fileName = info.filename;
|
|
const hash = crypto.createHash("sha256");
|
|
stream.on("data", (d: Buffer) => hash.update(d));
|
|
stream.on("end", () => {
|
|
received.hash = hash.digest("hex");
|
|
});
|
|
},
|
|
);
|
|
bb.on("close", () => {
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ message: "ok" }));
|
|
});
|
|
req.pipe(bb);
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
const port = (server.address() as { port: number }).port;
|
|
|
|
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-"));
|
|
const payload = crypto.randomBytes(3 * 1024 * 1024 + 11);
|
|
const localPath = path.join(root, "big.bin");
|
|
await fsp.writeFile(localPath, payload);
|
|
|
|
try {
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
|
});
|
|
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: "up-1",
|
|
origin: "local",
|
|
fields: { sessionId: "42", path: "/home/ubuntu" },
|
|
localPath,
|
|
fileName: "big renamed.bin",
|
|
url: "http://evil.example/exfil",
|
|
headers: { Authorization: "Bearer leaked" },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream");
|
|
expect(received.headers?.authorization).toBeUndefined();
|
|
expect(received.fields).toEqual({
|
|
sessionId: "42",
|
|
path: "/home/ubuntu",
|
|
});
|
|
expect(received.fileName).toBe("big renamed.bin");
|
|
expect(received.hash).toBe(
|
|
crypto.createHash("sha256").update(payload).digest("hex"),
|
|
);
|
|
} finally {
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("puts the renderer's local token on the wire as a Bearer header (cookie no longer required)", async () => {
|
|
const seen: http.IncomingHttpHeaders[] = [];
|
|
const server = http.createServer((req, res) => {
|
|
seen.push(req.headers);
|
|
req.resume();
|
|
req.on("end", () => {
|
|
// The real backend answers 401 "Missing authentication token" when
|
|
// neither cookie nor Bearer header is present.
|
|
if (!req.headers.authorization) {
|
|
res.statusCode = 401;
|
|
res.end(JSON.stringify({ error: "Missing authentication token" }));
|
|
return;
|
|
}
|
|
res.end(JSON.stringify({ message: "ok" }));
|
|
});
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
const port = (server.address() as { port: number }).port;
|
|
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-"));
|
|
const localPath = path.join(root, "small.txt");
|
|
await fsp.writeFile(localPath, "hello");
|
|
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln";
|
|
try {
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
|
});
|
|
const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: "up-auth-0",
|
|
origin: "local",
|
|
fields: { sessionId: "42", path: "/home/ubuntu" },
|
|
localPath,
|
|
fileName: "small.txt",
|
|
});
|
|
expect(withoutToken.success).toBe(false);
|
|
expect(withoutToken.error).toMatch(/Missing authentication token/);
|
|
|
|
const withToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: "up-auth-1",
|
|
origin: "local",
|
|
authToken: jwt,
|
|
fields: { sessionId: "42", path: "/home/ubuntu" },
|
|
localPath,
|
|
fileName: "small.txt",
|
|
headers: { Authorization: "Bearer leaked" },
|
|
});
|
|
expect(withToken.success).toBe(true);
|
|
expect(seen[1]?.authorization).toBe(`Bearer ${jwt}`);
|
|
expect(seen[1]?.["x-electron-app"]).toBe("true");
|
|
|
|
const malformed = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: "up-auth-2",
|
|
origin: "local",
|
|
authToken: "not a token\r\nX-Injected: 1",
|
|
fields: { sessionId: "42", path: "/home/ubuntu" },
|
|
localPath,
|
|
fileName: "small.txt",
|
|
});
|
|
expect(malformed.success).toBe(false);
|
|
expect(malformed.error).toMatch(/Invalid auth token/);
|
|
expect(seen.length).toBe(2); // the malformed one never reached the network
|
|
} finally {
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps every body intact when several uploads run at once and cancels only the requested one", async () => {
|
|
const Busboy = require("busboy") as (opts: {
|
|
headers: http.IncomingHttpHeaders;
|
|
}) => NodeJS.EventEmitter & NodeJS.WritableStream;
|
|
const received = new Map<string, string>(); // fileName -> sha256
|
|
let inFlight = 0;
|
|
let peak = 0;
|
|
const server = http.createServer((req, res) => {
|
|
inFlight += 1;
|
|
peak = Math.max(peak, inFlight);
|
|
const bb = Busboy({ headers: req.headers });
|
|
let name = "";
|
|
const hash = crypto.createHash("sha256");
|
|
bb.on(
|
|
"file",
|
|
(
|
|
_n: string,
|
|
stream: NodeJS.ReadableStream,
|
|
info: { filename: string },
|
|
) => {
|
|
name = info.filename;
|
|
stream.on("data", (d: Buffer) => hash.update(d));
|
|
},
|
|
);
|
|
bb.on("close", () => {
|
|
// Hold the response a little so requests genuinely overlap.
|
|
setTimeout(() => {
|
|
inFlight -= 1;
|
|
received.set(name, hash.digest("hex"));
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ message: "ok" }));
|
|
}, 120);
|
|
});
|
|
req.on("aborted", () => {
|
|
inFlight -= 1;
|
|
});
|
|
req.pipe(bb);
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
const port = (server.address() as { port: number }).port;
|
|
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-par-up-"));
|
|
const files = await Promise.all(
|
|
Array.from({ length: 6 }, async (_, i) => {
|
|
const payload = crypto.randomBytes(256 * 1024 + i);
|
|
const localPath = path.join(root, `f${i}.bin`);
|
|
await fsp.writeFile(localPath, payload);
|
|
return {
|
|
i,
|
|
localPath,
|
|
sha: crypto.createHash("sha256").update(payload).digest("hex"),
|
|
};
|
|
}),
|
|
);
|
|
try {
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
|
|
});
|
|
const results = await Promise.all(
|
|
files.map((f) =>
|
|
handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: `par-up-${f.i}`,
|
|
origin: "local",
|
|
fields: { sessionId: "1", path: "/remote" },
|
|
localPath: f.localPath,
|
|
fileName: `f${f.i}.bin`,
|
|
}),
|
|
),
|
|
);
|
|
expect(results.every((r) => r.success)).toBe(true);
|
|
expect(peak).toBeGreaterThan(1);
|
|
for (const f of files) expect(received.get(`f${f.i}.bin`)).toBe(f.sha);
|
|
} finally {
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("refuses an upload whose origin is not one of the two Termix backends", async () => {
|
|
const handlers = localFiles.createLocalFileHandlers({
|
|
net: fakeNet,
|
|
shell: {},
|
|
localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager",
|
|
});
|
|
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
|
|
transferId: "up-2",
|
|
origin: "http://evil.example",
|
|
fields: {},
|
|
localPath: __filename,
|
|
fileName: "x",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/Unknown transfer origin/);
|
|
});
|
|
});
|