fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

@@ -1,215 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const commandMocks = vi.hoisted(() => ({
execBuffer: vi.fn(),
execWithSudoBuffer: vi.fn(),
}));
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../hosts/file-manager/session.js")
>()),
execBuffer: commandMocks.execBuffer,
execWithSudoBuffer: commandMocks.execWithSudoBuffer,
}));
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
type RouteHandler = (request: Request, response: Response) => Promise<unknown>;
function setupReadRoute() {
const routes = new Map<string, RouteHandler>();
const app = {
get: (path: string, handler: RouteHandler) => routes.set(path, handler),
post: vi.fn(),
} as unknown as Express;
const session = {
isConnected: true,
lastActive: 0,
sudoPassword: "sudo-secret",
} as SSHSession;
registerFileContentRoutes(app, {
sshSessions: { session: session },
verifySessionOwnership: () => true,
});
const handler = routes.get("/ssh/file_manager/ssh/readFile");
if (!handler) throw new Error("readFile route was not registered");
const request = {
query: { sessionId: "session", path: "/root/secret.txt" },
userId: "user",
} as unknown as Request;
const response = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
vi.mocked(response.status).mockReturnValue(response);
vi.mocked(response.json).mockReturnValue(response);
return { handler, request, response, session };
}
describe("file manager readFile", () => {
beforeEach(() => {
commandMocks.execBuffer.mockReset();
commandMocks.execWithSudoBuffer.mockReset();
});
it("falls back to sudo for both the size check and content read", async () => {
const { handler, request, response, session } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("6\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("secret"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
1,
session,
"stat -c%s '/root/secret.txt'",
"sudo-secret",
);
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
"sudo-secret",
500 * 1024 * 1024,
);
expect(response.json).toHaveBeenCalledWith({
content: "secret",
path: "/root/secret.txt",
encoding: "utf8",
});
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/root/secret.txt'",
500 * 1024 * 1024,
);
});
it("rejects a file that grows beyond the limit while being read", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Command output exceeds 524288000 bytes",
code: 1,
exceededLimit: true,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
maxSize: 500 * 1024 * 1024,
tooLarge: true,
}),
);
});
it("keeps readable files on the unprivileged path", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("hello"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execWithSudoBuffer).not.toHaveBeenCalled();
expect(response.json).toHaveBeenCalledWith({
content: "hello",
path: "/root/secret.txt",
encoding: "utf8",
});
});
it("preserves percent escapes that are literal path characters", async () => {
const { handler, request, response, session } = setupReadRoute();
request.query.path = "/tmp/100%/literal%2Fname";
commandMocks.execBuffer
.mockResolvedValueOnce({
stdout: Buffer.from("5\n"),
stderr: "",
code: 0,
})
.mockResolvedValueOnce({
stdout: Buffer.from("hello"),
stderr: "",
code: 0,
});
await handler(request, response);
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
2,
session,
"cat '/tmp/100%/literal%2Fname'",
500 * 1024 * 1024,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ path: "/tmp/100%/literal%2Fname" }),
);
});
it("rejects oversized sudo-only files before reading their content", async () => {
const { handler, request, response } = setupReadRoute();
commandMocks.execBuffer.mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: "Permission denied",
code: 1,
});
commandMocks.execWithSudoBuffer.mockResolvedValueOnce({
stdout: Buffer.from(String(501 * 1024 * 1024)),
stderr: "",
code: 0,
});
await handler(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ tooLarge: true }),
);
expect(commandMocks.execBuffer).toHaveBeenCalledTimes(1);
expect(commandMocks.execWithSudoBuffer).toHaveBeenCalledTimes(1);
});
});
@@ -1,96 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const mocks = vi.hoisted(() => ({
getSessionSftp: vi.fn(),
execChannel: vi.fn(),
}));
vi.mock("../../../hosts/file-manager/session.js", () => ({
...mocks,
execWithSudo: vi.fn(),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: { getInstance: () => ({}) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: vi.fn(),
getCurrentSettingValue: vi.fn(),
}));
import { registerFileOperationRoutes } from "../../../hosts/file-manager/operation-routes.js";
type Handler = (req: Request, res: Response) => Promise<unknown>;
function setup(owned = true) {
const routes = new Map<string, Handler>();
const app = {
get: vi.fn(),
put: vi.fn(),
post: (url: string, fn: Handler) => routes.set(url, fn),
delete: vi.fn(),
} as unknown as Express;
registerFileOperationRoutes(app, {
sshSessions: { test: { isConnected: true } as SSHSession },
verifySessionOwnership: () => owned,
});
const res = { status: vi.fn(), json: vi.fn() };
res.status.mockReturnValue(res);
const req = {
userId: "owner",
body: { sessionId: "test", path: "/tmp", folderName: "parent/child" },
} as unknown as Request;
return {
run: () =>
routes.get("/ssh/file_manager/ssh/createFolder")!(
req,
res as unknown as Response,
),
res,
};
}
beforeEach(() => vi.clearAllMocks());
it("creates nested folders over SFTP when the server cannot execute shell commands", async () => {
const mkdir = vi.fn((path, _attrs, cb) =>
cb(path === "/tmp" ? { code: 4 } : null),
);
mocks.getSessionSftp.mockResolvedValue({
mkdir,
stat: (
_path: string,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(null, { isDirectory: () => true }),
});
const { run, res } = setup();
await run();
expect(mkdir.mock.calls.map((call) => call[0])).toEqual([
"/tmp",
"/tmp/parent",
"/tmp/parent/child",
]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ path: "/tmp/parent/child" }),
);
expect(mocks.execChannel).not.toHaveBeenCalled();
});
it("reports permission denial instead of success", async () => {
const error = Object.assign(new Error("Permission denied"), { code: 3 });
mocks.getSessionSftp.mockResolvedValue({
mkdir: (
_p: string,
_a: unknown,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(error),
stat: (
_p: string,
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
) => cb(error),
});
const { run, res } = setup();
await run();
expect(res.status).toHaveBeenCalledWith(403);
});
it("rejects a session belonging to another user before opening SFTP", async () => {
const { run, res } = setup(false);
await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(mocks.getSessionSftp).not.toHaveBeenCalled();
});
@@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildDirectProbeCommand,
buildDirectRsyncCommand,
quoteShell,
shouldBenchmarkDirectTransfer,
shouldUseDirectTransfer,
} from "../../../hosts/file-manager/direct-transfer-routing.js";
const endpoint = { host: "10.0.0.2", port: 2222, username: "deploy" };
describe("direct transfer routing", () => {
it("keeps small transfers on the relay without benchmarking", () => {
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024 - 1)).toBe(false);
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024)).toBe(true);
});
it("requires a meaningful speed advantage", () => {
expect(shouldUseDirectTransfer(700, 1000)).toBe(true);
expect(shouldUseDirectTransfer(850, 1000)).toBe(false);
expect(shouldUseDirectTransfer(0, 1000)).toBe(false);
});
it("probes without accepting passwords or unknown host keys", () => {
const command = buildDirectProbeCommand(endpoint);
expect(command).toContain("BatchMode=yes");
expect(command).toContain("StrictHostKeyChecking=yes");
expect(command).toContain("ConnectTimeout=5");
expect(command).toContain("-p 2222");
});
it("quotes source and destination paths for rsync", () => {
const command = buildDirectRsyncCommand(
endpoint,
["/srv/a file", "/srv/it's-safe"],
"/opt/releases",
true,
);
expect(command).toContain("--partial --append-verify");
expect(command).toContain("--protect-args");
expect(command).toContain(quoteShell("/srv/a file"));
expect(command).toContain(quoteShell("/srv/it's-safe"));
expect(command).toContain("deploy@10.0.0.2:/opt/releases/");
});
it("brackets IPv6 destinations", () => {
const command = buildDirectProbeCommand({
host: "2001:db8::2",
port: 22,
username: "root",
});
expect(command).toContain("root@[2001:db8::2]");
});
});
@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDeleteCommand } from "../../../hosts/file-manager/operation-commands.js";
describe("buildDeleteCommand", () => {
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
const command = buildDeleteCommand(
"/C:/Users/Administrator/test.txt",
false,
);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
);
expect(command.commandWithSuccess).toBe(
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
);
expect(command.commandWithSuccess).not.toContain("&&");
});
it("adds recursive deletion for Windows directories", () => {
const command = buildDeleteCommand("C:/Temp/Folder", true);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
);
});
it("escapes single quotes in Windows literal paths", () => {
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
expect(command.command).toBe(
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
);
});
it("keeps POSIX delete commands using shell success chaining", () => {
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
expect(command.commandWithSuccess).toBe(
`${command.command} && echo "SUCCESS"`,
);
});
});
@@ -1,68 +0,0 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
ChannelOpenSerializer,
execBuffer,
execWithSudoBuffer,
type SSHSession,
} from "../../../hosts/file-manager/session.js";
function setupSession() {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
close: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
stream.stderr = new EventEmitter();
stream.close = vi.fn();
stream.write = vi.fn();
const exec = vi.fn(
(
_command: string,
callback: (error: undefined, channel: typeof stream) => void,
) => callback(undefined, stream),
);
const session = {
client: { exec },
channelOpener: new ChannelOpenSerializer(),
} as unknown as SSHSession;
return { exec, session, stream };
}
describe("file-manager command helpers", () => {
it("writes the sudo password to stdin instead of the command line", async () => {
const { exec, session, stream } = setupSession();
const resultPromise = execWithSudoBuffer(
session,
"cat '/root/secret.txt'",
"private password",
);
await vi.waitFor(() => expect(stream.write).toHaveBeenCalled());
expect(exec).toHaveBeenCalledWith(
"sudo -S -p '' cat '/root/secret.txt' 2>&1",
expect.any(Function),
);
expect(exec.mock.calls[0][0]).not.toContain("private password");
expect(stream.write).toHaveBeenCalledWith("private password\n");
stream.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
});
it("closes the channel when stdout exceeds the configured limit", async () => {
const { session, stream } = setupSession();
const resultPromise = execBuffer(session, "cat file", 4);
await vi.waitFor(() => expect(stream.listenerCount("data")).toBe(1));
stream.emit("data", Buffer.from("12345"));
await expect(resultPromise).resolves.toMatchObject({
code: 1,
exceededLimit: true,
});
expect(stream.close).toHaveBeenCalledOnce();
});
});
@@ -1,68 +0,0 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import {
hashSftpFile,
verifySftpFileIntegrity,
} from "../../../hosts/file-manager/transfer-integrity.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
function fakeSftp(contents: Record<string, Buffer | Buffer[]>): SFTPWrapper {
return {
createReadStream: vi.fn((path: string) => {
const value = contents[path];
if (value === undefined) {
return new Readable({
read() {
this.destroy(new Error(`Missing file: ${path}`));
},
});
}
return Readable.from(Array.isArray(value) ? value : [value]);
}),
} as unknown as SFTPWrapper;
}
describe("transfer integrity", () => {
it("hashes all chunks in an SFTP file", async () => {
const sftp = fakeSftp({
"/file": [Buffer.from("hello "), Buffer.from("world")],
});
await expect(hashSftpFile(sftp, "/file")).resolves.toBe(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
);
});
it("accepts matching source and destination files", async () => {
const source = fakeSftp({ "/source": Buffer.from("same bytes") });
const dest = fakeSftp({ "/dest": Buffer.from("same bytes") });
await expect(
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
).resolves.toMatchObject({ algorithm: "sha256" });
});
it("rejects a corrupted destination", async () => {
const source = fakeSftp({ "/source": Buffer.from("expected") });
const dest = fakeSftp({ "/dest": Buffer.from("corrupted") });
await expect(
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
).rejects.toThrow("SHA-256 verification failed for /source");
});
it("aborts hashing with the caller's cancellation error", async () => {
const sftp = fakeSftp({ "/file": Buffer.from("data") });
const cancelled = new Error("cancelled by test");
await expect(
hashSftpFile(
sftp,
"/file",
() => true,
() => cancelled,
),
).rejects.toBe(cancelled);
});
});
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import {
estimateIncompressibleSample,
resolveArchiveTransferMethod,
type TransferScanSummary,
} from "../../../hosts/file-manager/transfer-routing.js";
describe("transfer content sampling", () => {
it("recognizes repetitive content as compressible", () => {
expect(estimateIncompressibleSample(Buffer.alloc(64 * 1024, 65))).toBe(
false,
);
});
it("recognizes high-entropy content as incompressible", () => {
const sample = Buffer.alloc(64 * 1024);
let state = 0x12345678;
for (let i = 0; i < sample.length; i++) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
sample[i] = state & 0xff;
}
expect(estimateIncompressibleSample(sample)).toBe(true);
});
it("prefers sampled content over a misleading extension", () => {
const summary: TransferScanSummary = {
fileCount: 120,
totalBytes: 1024 * 1024 * 1024,
largestFileBytes: 32 * 1024 * 1024,
incompressibleRatio: 0,
sampledIncompressibleRatio: 1,
};
expect(
resolveArchiveTransferMethod("auto", summary, "unix", "unix", true, true),
).toBe("item_sftp");
});
});
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
clearTransferProfiles,
flushTransferProfiles,
getRecentDirectRouteDecision,
getTransferProfile,
initializeTransferProfiles,
recordDirectRouteBenchmark,
recordDirectRouteOutcome,
recordTransferProfile,
selectTransferTuning,
updateTransferProfile,
} from "../../../hosts/file-manager/transfer-tuning.js";
const MB = 1024 * 1024;
describe("adaptive transfer tuning", () => {
beforeEach(clearTransferProfiles);
it("keeps small files on one conservative lane", () => {
expect(selectTransferTuning(8 * MB)).toEqual({
parallelSegmentCount: 1,
pipelineConcurrency: 8,
});
});
it("uses more lanes for large transfers without exceeding segment count", () => {
expect(selectTransferTuning(2 * 1024 * MB)).toEqual({
parallelSegmentCount: 4,
pipelineConcurrency: 32,
});
expect(selectTransferTuning(300 * MB).parallelSegmentCount).toBe(2);
});
it("honours an explicit lane selection", () => {
expect(selectTransferTuning(2 * 1024 * MB, undefined, 3)).toMatchObject({
parallelSegmentCount: 3,
});
});
it("backs off after failures", () => {
const profile = updateTransferProfile(undefined, {
bytes: 256 * MB,
durationMs: 1000,
lanes: 4,
pipelineConcurrency: 32,
failed: true,
now: 1,
});
expect(profile.preferredLanes).toBe(2);
expect(profile.pipelineConcurrency).toBe(16);
});
it("learns and expires host-pair profiles", () => {
recordTransferProfile("a->b", {
bytes: 256 * MB,
durationMs: 2000,
lanes: 2,
pipelineConcurrency: 32,
failed: false,
now: 100,
});
expect(getTransferProfile("a->b", 101)?.samples).toBe(1);
expect(getTransferProfile("a->b", 8 * 24 * 60 * 60 * 1000)).toBeUndefined();
});
it("reuses recent route benchmarks and cools down failed direct paths", () => {
recordDirectRouteBenchmark("a->b", 700, 1000, 100);
expect(getRecentDirectRouteDecision("a->b", 1_000, 101)).toEqual({
useDirect: true,
directMs: 700,
relayMs: 1000,
});
recordDirectRouteOutcome("a->b", true, 102, 500);
expect(getRecentDirectRouteDecision("a->b", 1_000, 103)?.useDirect).toBe(
false,
);
expect(getRecentDirectRouteDecision("a->b", 1_000, 1_103)).toBeUndefined();
});
it("persists only anonymous local profiles across restarts", async () => {
const previousDataDir = process.env.DATA_DIR;
const dataDir = await fs.mkdtemp(path.join(tmpdir(), "termix-transfer-"));
process.env.DATA_DIR = dataDir;
const rawKey = "root@10.0.0.1->deploy@10.0.0.2";
const now = Date.now();
try {
await initializeTransferProfiles();
recordTransferProfile(rawKey, {
bytes: 256 * MB,
durationMs: 2000,
lanes: 2,
pipelineConcurrency: 16,
failed: false,
now,
});
recordDirectRouteBenchmark(rawKey, 700, 1000, now);
await flushTransferProfiles();
const stored = await fs.readFile(
path.join(dataDir, "adaptive-transfer-profiles.json"),
"utf8",
);
expect(stored).not.toContain(rawKey);
expect(Object.keys(JSON.parse(stored).profiles)[0]).toMatch(
/^[a-f0-9]{64}$/,
);
clearTransferProfiles();
await initializeTransferProfiles();
expect(getTransferProfile(rawKey, now + 1)).toMatchObject({ samples: 1 });
expect(
getRecentDirectRouteDecision(rawKey, 1_000, now + 1),
).toMatchObject({ useDirect: true });
} finally {
clearTransferProfiles();
if (previousDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = previousDataDir;
await fs.rm(dataDir, { recursive: true, force: true });
}
});
});
@@ -1,235 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { SFTPWrapper } from "ssh2";
import { afterEach, describe, expect, it } from "vitest";
import {
isSafeTrashSource,
listTrash,
moveToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "../../../hosts/file-manager/trash-service.js";
const temporaryDirectories: string[] = [];
// Windows only allows symlink creation with elevation or Developer Mode, so the
// symlink safety test is skipped where the OS refuses to create one at all.
const canCreateSymlinks = (() => {
const probe = fs.mkdtempSync(path.join(os.tmpdir(), "termix-symlink-probe-"));
try {
fs.mkdirSync(path.join(probe, "target"));
fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link"), "dir");
return true;
} catch {
return false;
} finally {
fs.rmSync(probe, { recursive: true, force: true });
}
})();
function localSftp(home: string): SFTPWrapper {
const callback = <T>(
promise: Promise<T>,
done: (error: Error | undefined, value?: T) => void,
) =>
promise
.then((value) => done(undefined, value))
.catch((error) => done(error));
return {
realpath(
_target: string,
done: (error: Error | undefined, value?: string) => void,
) {
done(undefined, home);
},
stat(
target: string,
done: (error: Error | undefined, value?: fs.Stats) => void,
) {
callback(fs.promises.stat(target), done);
},
lstat(
target: string,
done: (error: Error | undefined, value?: fs.Stats) => void,
) {
callback(fs.promises.lstat(target), done);
},
readdir(
target: string,
done: (error: Error | undefined, value?: unknown[]) => void,
) {
callback(
fs.promises.readdir(target, { withFileTypes: true }).then((entries) =>
entries.map((entry) => ({
filename: entry.name,
longname: entry.name,
attrs: {},
})),
),
done,
);
},
readFile(
target: string,
done: (error: Error | undefined, value?: Buffer) => void,
) {
callback(fs.promises.readFile(target), done);
},
writeFile(target: string, data: string, done: (error?: Error) => void) {
fs.promises
.writeFile(target, data)
.then(() => done())
.catch(done);
},
rename(from: string, to: string, done: (error?: Error) => void) {
fs.promises
.rename(from, to)
.then(() => done())
.catch(done);
},
unlink(target: string, done: (error?: Error) => void) {
fs.promises
.unlink(target)
.then(() => done())
.catch(done);
},
mkdir(target: string, done: (error?: Error) => void) {
fs.promises
.mkdir(target)
.then(() => done())
.catch(done);
},
rmdir(target: string, done: (error?: Error) => void) {
fs.promises
.rmdir(target)
.then(() => done())
.catch(done);
},
} as unknown as SFTPWrapper;
}
async function fixture() {
const home = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "termix-trash-"),
);
temporaryDirectories.push(home);
return { home, sftp: localSftp(home) };
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) =>
fs.promises.rm(directory, { recursive: true, force: true }),
),
);
});
describe("file manager trash safety", () => {
it("rejects roots and paths inside the trash", () => {
expect(isSafeTrashSource("/", "/home/user/.termix-trash")).toBe(false);
expect(isSafeTrashSource("C:/", "C:/Users/user/.termix-trash")).toBe(false);
expect(
isSafeTrashSource(
"/home/user/.termix-trash/files/a",
"/home/user/.termix-trash",
),
).toBe(false);
});
it("accepts ordinary files and directories", () => {
expect(
isSafeTrashSource("/home/user/report.txt", "/home/user/.termix-trash"),
).toBe(true);
});
it("moves, lists, and restores a file without changing its contents", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "report.txt");
await fs.promises.writeFile(original, "important");
const trashed = await moveToTrash(sftp, original);
expect(fs.existsSync(original)).toBe(false);
expect(await listTrash(sftp, 7)).toEqual([trashed]);
await restoreTrashItem(sftp, trashed.id);
expect(await fs.promises.readFile(original, "utf8")).toBe("important");
expect(await listTrash(sftp, 7)).toEqual([]);
});
it("permanently deletes only the stored trash path", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "folder");
await fs.promises.mkdir(original);
await fs.promises.writeFile(path.join(original, "nested.txt"), "data");
const trashed = await moveToTrash(sftp, original);
await permanentlyDeleteTrashItem(sftp, trashed.id);
expect(await listTrash(sftp, 7)).toEqual([]);
});
it.skipIf(!canCreateSymlinks)(
"does not follow directory symlinks during permanent deletion",
async () => {
const { home, sftp } = await fixture();
const target = path.join(home, "target");
const link = path.join(home, "link");
await fs.promises.mkdir(target);
await fs.promises.writeFile(path.join(target, "keep.txt"), "keep");
await fs.promises.symlink(target, link, "dir");
const trashed = await moveToTrash(sftp, link);
await permanentlyDeleteTrashItem(sftp, trashed.id);
expect(
await fs.promises.readFile(path.join(target, "keep.txt"), "utf8"),
).toBe("keep");
},
);
it("refuses tampered metadata instead of deleting an arbitrary path", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "discard.txt");
const protectedFile = path.join(home, "keep.txt");
await fs.promises.writeFile(original, "discard");
await fs.promises.writeFile(protectedFile, "keep");
const trashed = await moveToTrash(sftp, original);
const metadata = path.join(
home,
".termix-trash",
"info",
`${trashed.id}.json`,
);
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
data.trashPath = protectedFile;
await fs.promises.writeFile(metadata, JSON.stringify(data));
await expect(permanentlyDeleteTrashItem(sftp, trashed.id)).rejects.toThrow(
"Invalid trash metadata",
);
expect(await fs.promises.readFile(protectedFile, "utf8")).toBe("keep");
});
it("prunes items after the configured retention period", async () => {
const { home, sftp } = await fixture();
const original = path.join(home, "old.txt");
await fs.promises.writeFile(original, "old");
const trashed = await moveToTrash(sftp, original);
const metadata = path.join(
home,
".termix-trash",
"info",
`${trashed.id}.json`,
);
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
data.deletedAt = "2020-01-01T00:00:00.000Z";
await fs.promises.writeFile(metadata, JSON.stringify(data));
expect(await listTrash(sftp, 7)).toEqual([]);
expect(
fs.existsSync(path.join(home, ".termix-trash", "files", trashed.id)),
).toBe(false);
});
});
@@ -1,198 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Express, Request, Response } from "express";
import { PassThrough, Writable } from "node:stream";
import type { SSHSession } from "../../../hosts/file-manager/session.js";
const mocks = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../hosts/file-manager/session.js")
>()),
getSessionSftp: mocks.getSessionSftp,
}));
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
type RouteHandler = (request: Request, response: Response) => unknown;
/**
* Behaves like ssh2's SFTP WriteStream on current Node: it destroys itself
* inside `_final` (autoClose), so a `finish` event is never emitted; only
* `close` follows the last acknowledged write.
*/
class Ssh2LikeWriteStream extends Writable {
chunks: Buffer[] = [];
constructor() {
super({ emitClose: false, autoDestroy: false });
}
_write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
this.chunks.push(Buffer.from(chunk));
cb();
}
_final(cb: () => void) {
this.destroy();
cb();
}
_destroy(err: Error | null, cb: (err: Error | null) => void) {
// The handle close round-trip happens later; 'close' is asynchronous.
setTimeout(() => {
cb(err);
this.emit("close");
}, 5);
}
get body() {
return Buffer.concat(this.chunks);
}
}
function setup() {
const routes = new Map<string, RouteHandler>();
const app = {
get: vi.fn(),
post: (path: string, handler: RouteHandler) => routes.set(path, handler),
} as unknown as Express;
const session = { isConnected: true, lastActive: 0 } as SSHSession;
registerFileContentRoutes(app, {
sshSessions: { s1: session },
verifySessionOwnership: () => true,
});
const writeStream = new Ssh2LikeWriteStream();
const unlink = vi.fn((_p: string, cb: (e: null) => void) => cb(null));
mocks.getSessionSftp.mockResolvedValue({
createWriteStream: vi.fn(() => writeStream),
unlink,
});
const response = { status: vi.fn(), json: vi.fn() } as unknown as Response;
vi.mocked(response.status).mockReturnValue(response);
const responded = new Promise<unknown>((resolve) => {
vi.mocked(response.json).mockImplementation((payload: unknown) => {
resolve(payload);
return response;
});
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("route never responded")), 2000),
);
return {
routes,
writeStream,
unlink,
response,
responded: Promise.race([responded, timeout]),
};
}
beforeEach(() => mocks.getSessionSftp.mockReset());
describe("uploadFileStream completion", () => {
function multipartRequest(body: Buffer, fields: Record<string, string>) {
const boundary = "----termixTestBoundary";
let preamble = "";
for (const [k, v] of Object.entries(fields)) {
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`;
}
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`;
const req = new PassThrough() as PassThrough & Request;
Object.assign(req, {
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
userId: "u1",
complete: false,
});
const send = async () => {
req.write(preamble);
req.write(body);
req.write(`\r\n--${boundary}--\r\n`);
(req as { complete: boolean }).complete = true;
req.end();
};
return { req, send };
}
it("answers once the SFTP stream closes, even though ssh2 never emits 'finish'", async () => {
const { routes, writeStream, response, responded } = setup();
const payload = Buffer.from("hello over sftp");
const { req, send } = multipartRequest(payload, {
sessionId: "s1",
path: "/home/ubuntu",
});
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
await send();
const answer = (await responded) as { message: string; path: string };
expect(answer.message).toBe("File uploaded successfully");
expect(answer.path).toBe("/home/ubuntu/payload.bin");
expect(writeStream.body.equals(payload)).toBe(true);
expect(vi.mocked(response.status)).not.toHaveBeenCalled();
});
it("does not report success when the client aborts before the body ended", async () => {
const { routes, writeStream, unlink, response } = setup();
const { req } = multipartRequest(Buffer.alloc(0), {
sessionId: "s1",
path: "/home/ubuntu",
});
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
// Headers + the beginning of the file part arrive, then the socket dies.
req.write(
`------termixTestBoundary\r\nContent-Disposition: form-data; name="sessionId"\r\n\r\ns1\r\n` +
`------termixTestBoundary\r\nContent-Disposition: form-data; name="path"\r\n\r\n/home/ubuntu\r\n` +
`------termixTestBoundary\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\n\r\npartial`,
);
await new Promise((r) => setTimeout(r, 20));
req.emit("aborted");
// The partial is removed and its stream closes; that close is not a success.
await new Promise((r) => setTimeout(r, 50));
expect(writeStream.destroyed).toBe(true);
expect(unlink).toHaveBeenCalledWith(
"/home/ubuntu/payload.bin",
expect.any(Function),
);
expect(vi.mocked(response.json)).not.toHaveBeenCalled();
});
});
describe("uploadFileChunk completion", () => {
function chunkRequest(query: Record<string, string>) {
const req = new PassThrough() as PassThrough & Request;
Object.assign(req, { query, headers: {}, userId: "u1" });
return req;
}
const query = {
sessionId: "s1",
path: "/home/ubuntu",
fileName: "big.bin",
offset: "0",
totalSize: "6",
};
it("streams the raw body and answers on the ssh2 'close' event", async () => {
const { routes, writeStream, response, responded } = setup();
const req = chunkRequest(query);
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
await new Promise((r) => setTimeout(r, 10)); // let getSessionSftp resolve
req.write("abc");
req.end("def");
const answer = (await responded) as {
bytesWritten: number;
nextOffset: number;
complete: boolean;
};
expect(answer).toMatchObject({
bytesWritten: 6,
nextOffset: 6,
complete: true,
});
expect(writeStream.body.toString()).toBe("abcdef");
});
it("still writes the chunk when middleware already drained the body into req.body", async () => {
const { routes, writeStream, response, responded } = setup();
const req = chunkRequest(query);
// What express.raw() leaves behind: a Buffer body and an ended stream.
Object.assign(req, { body: Buffer.from("abcdef") });
req.end();
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
const answer = (await responded) as { bytesWritten: number };
expect(answer.bytesWritten).toBe(6);
expect(writeStream.body.toString()).toBe("abcdef");
});
});
@@ -1,150 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isExecutableFile,
modeToPermissions,
formatMtime,
getMimeType,
detectBinary,
parseLsDateToTimestamp,
} from "../../../hosts/file-manager/utils.js";
describe("isExecutableFile", () => {
it("flags scripts with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
});
it("flags known executable extensions with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
});
it("flags extensionless files with execute permission", () => {
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
});
it("does not flag files without execute permission", () => {
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
});
it("does not flag non-script data files even when executable", () => {
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
});
});
describe("modeToPermissions", () => {
it("renders a regular file with rwxr-xr-x", () => {
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
});
it("renders a directory prefix", () => {
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
});
it("renders a symlink prefix", () => {
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
});
it("renders a read-only file", () => {
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
});
it("renders no permissions", () => {
expect(modeToPermissions(0o100000)).toBe("----------");
});
});
describe("formatMtime", () => {
it("uses HH:MM format for recent timestamps", () => {
// Within the last 6 months relative to now.
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
const result = formatMtime(recent);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
});
it("uses the year for old timestamps", () => {
// ~2 years ago is comfortably outside the 6-month window.
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
const result = formatMtime(old);
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
});
});
describe("getMimeType", () => {
it("maps known extensions", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
expect(getMimeType("data.json")).toBe("application/json");
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("falls back to octet-stream for unknown or missing extensions", () => {
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
expect(getMimeType("noextension")).toBe("application/octet-stream");
});
});
describe("parseLsDateToTimestamp", () => {
it("parses a year-format date string", () => {
const ts = parseLsDateToTimestamp("Dec 12 2025");
const d = new Date(ts * 1000);
expect(d.getFullYear()).toBe(2025);
expect(d.getMonth()).toBe(11);
expect(d.getDate()).toBe(12);
});
it("parses a time-format date string for a recent file", () => {
const now = new Date();
const month = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
][now.getMonth()];
const day = now.getDate();
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
const d = new Date(ts * 1000);
expect(d.getHours()).toBe(10);
expect(d.getMinutes()).toBe(30);
});
it("returns 0 for an empty or invalid string", () => {
expect(parseLsDateToTimestamp("")).toBe(0);
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
});
it("produces ascending order for older vs newer dates", () => {
const older = parseLsDateToTimestamp("Jan 1 2020");
const newer = parseLsDateToTimestamp("Dec 31 2024");
expect(older).toBeLessThan(newer);
});
});
describe("detectBinary", () => {
it("returns false for empty buffers", () => {
expect(detectBinary(Buffer.from([]))).toBe(false);
});
it("returns false for plain UTF-8 text", () => {
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
false,
);
});
it("returns true when null bytes are present", () => {
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
});
it("allows common whitespace control characters", () => {
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
});
});
@@ -31,7 +31,11 @@ function target(id: number, extra: Partial<Target> = {}): Target {
};
}
function setup(targets: Target[], reachable = true) {
function setup(
targets: Target[],
reachable = true,
shared: Record<string, number[]> = {},
) {
const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable);
const pingThroughJumpHosts = vi.fn(async () => reachable);
@@ -45,6 +49,7 @@ function setup(targets: Target[], reachable = true) {
);
const service = new HostStatusService({
loadTargets,
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
globalInterval: () => 60,
@@ -87,6 +92,30 @@ describe("HostStatusService", () => {
});
});
it("checks a host shared with the user before its owner ever asks", async () => {
const { service, ping } = setup([target(5)], true, { recipient: [5] });
active = service;
await service.statusesFor("recipient", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
expect(service.get(5)?.status).toBe("reachable");
});
it("only starts the shared hosts a desktop request names", async () => {
const { service, ping } = setup([target(5), target(6)], true, {
recipient: [5, 6],
});
active = service;
await service.statusesFor("recipient", new Set([6]));
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
});
it("reports offline for a host that does not answer", async () => {
const { service } = setup([target(1)], false);
active = service;