360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
readFileTransferConcurrency,
|
|
runSshFileTransferOperation,
|
|
type SshFileTransferCommandBuilders,
|
|
type SshRemoteCommandExecutor,
|
|
} from "./ssh-file-transfer";
|
|
import { parseSshInvocation } from "./ssh";
|
|
|
|
function fakeTransfer(content: Buffer): { executor: SshRemoteCommandExecutor; builders: SshFileTransferCommandBuilders; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
return {
|
|
calls,
|
|
builders: {
|
|
buildRouteCommand(_route, command) {
|
|
return JSON.stringify(command);
|
|
},
|
|
buildWindowsPowerShellCommand(script) {
|
|
return script;
|
|
},
|
|
},
|
|
executor: {
|
|
streamInactivityTimeoutMs: 60_000,
|
|
async runRemoteCommand(remoteCommand) {
|
|
const command = JSON.parse(remoteCommand) as string[];
|
|
const marker = command.indexOf("unidesk-file-transfer");
|
|
const operation = command[marker + 1] ?? "unknown";
|
|
calls.push(operation);
|
|
return operation === "stat"
|
|
? { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" }
|
|
: { exitCode: 0, stdout: "", stderr: "" };
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("ssh text transfer preflight", () => {
|
|
test("reads transfer concurrency from YAML", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-transfer-config-"));
|
|
const configPath = join(dir, "unidesk-cli.yaml");
|
|
writeFileSync(configPath, "trans:\n fileTransfer:\n concurrency: 3\n transportOpenRetries: 2\n transportRetryDelayMs: 0\n");
|
|
try {
|
|
expect(readFileTransferConcurrency(configPath)).toBe(3);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("rejects a known text download before remote work or local target creation", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-text-transfer-"));
|
|
const target = join(dir, "result.md");
|
|
const fake = fakeTransfer(Buffer.from("text\n"));
|
|
const invocation = parseSshInvocation("D601:/tmp", ["download", "/tmp/result.md", target]);
|
|
try {
|
|
await runSshFileTransferOperation(invocation, ["download", "/tmp/result.md", target], fake.executor, fake.builders);
|
|
throw new Error("expected text download preflight to fail");
|
|
} catch (error) {
|
|
const typed = error as Error & { details?: Record<string, unknown> };
|
|
expect(typed.name).toBe("SshFileTransferError");
|
|
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
|
expect(typed.details?.targetCreated).toBe(false);
|
|
expect(typed.details?.remoteOperationStarted).toBe(false);
|
|
expect(JSON.stringify(typed.details)).toContain("trans D601:/tmp cat");
|
|
expect(fake.calls).toEqual([]);
|
|
expect(existsSync(target)).toBe(false);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("rejects a known text upload before reading the local source or starting remote work", async () => {
|
|
const fake = fakeTransfer(Buffer.from("text\n"));
|
|
const missingSource = "/tmp/unidesk-text-transfer-source-does-not-exist.md";
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", missingSource, "/tmp/result.md"]);
|
|
try {
|
|
await runSshFileTransferOperation(invocation, ["upload", missingSource, "/tmp/result.md"], fake.executor, fake.builders);
|
|
throw new Error("expected text upload preflight to fail");
|
|
} catch (error) {
|
|
const typed = error as Error & { details?: Record<string, unknown> };
|
|
expect(typed.name).toBe("SshFileTransferError");
|
|
expect(typed.details?.code).toBe("text-transfer-discouraged");
|
|
expect(JSON.stringify(typed.details)).toContain("apply-patch <<'PATCH'");
|
|
expect(fake.calls).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("keeps verified binary upload as the default transfer workflow", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-binary-transfer-"));
|
|
const source = join(dir, "firmware.bin");
|
|
const content = Buffer.from([0, 1, 2, 3, 255]);
|
|
writeFileSync(source, content);
|
|
const fake = fakeTransfer(content);
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/firmware.bin"]);
|
|
try {
|
|
expect(await runSshFileTransferOperation(invocation, ["upload", source, "/tmp/firmware.bin"], fake.executor, fake.builders)).toBe(0);
|
|
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retries only pre-dispatch transport failures before running the transfer helper", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-transfer-open-retry-"));
|
|
const source = join(dir, "firmware.bin");
|
|
const content = Buffer.from([0, 1, 2, 3]);
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
writeFileSync(source, content);
|
|
let attempts = 0;
|
|
const fake = fakeTransfer(content);
|
|
fake.executor.runRemoteCommand = async (remoteCommand) => {
|
|
const command = JSON.parse(remoteCommand) as string[];
|
|
const operation = command[command.indexOf("unidesk-file-transfer") + 1] ?? "unknown";
|
|
fake.calls.push(operation);
|
|
if (operation === "write-b64-argv" && attempts++ === 0) {
|
|
return { exitCode: 255, stdout: "", stderr: "unidesk remote frontend ssh bridge timed out waiting for provider session\n" };
|
|
}
|
|
return operation === "stat"
|
|
? { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" }
|
|
: { exitCode: 0, stdout: "", stderr: "" };
|
|
};
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/firmware.bin"]);
|
|
try {
|
|
expect(await runSshFileTransferOperation(invocation, ["upload", source, "/tmp/firmware.bin"], fake.executor, fake.builders)).toBe(0);
|
|
expect(fake.calls).toEqual(["write-b64-argv", "write-b64-argv", "stat"]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("uploads files above one chunk with positional base64 blocks", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-chunked-transfer-"));
|
|
const source = join(dir, "payload.bin");
|
|
const content = Buffer.alloc((3 * 1024 * 1024) + 17, 0xa5);
|
|
writeFileSync(source, content);
|
|
const fake = fakeTransfer(content);
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/payload.bin"]);
|
|
try {
|
|
expect(await runSshFileTransferOperation(
|
|
invocation,
|
|
["upload", source, "/tmp/payload.bin"],
|
|
fake.executor,
|
|
fake.builders,
|
|
)).toBe(0);
|
|
expect(fake.calls).toEqual([
|
|
"write-b64-begin",
|
|
"write-b64-block-stdin",
|
|
"write-b64-block-stdin",
|
|
"write-b64-commit",
|
|
"stat",
|
|
]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("limits parallel upload blocks to the configured concurrency", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-parallel-upload-"));
|
|
const source = join(dir, "payload.bin");
|
|
const content = Buffer.alloc((8 * 3 * 1024 * 1024) + 17, 0x5a);
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
writeFileSync(source, content);
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
let blocks = 0;
|
|
const builders: SshFileTransferCommandBuilders = {
|
|
buildRouteCommand(_route, command) {
|
|
return JSON.stringify(command);
|
|
},
|
|
buildWindowsPowerShellCommand(script) {
|
|
return script;
|
|
},
|
|
};
|
|
const executor: SshRemoteCommandExecutor = {
|
|
streamInactivityTimeoutMs: 60_000,
|
|
async runRemoteCommand(remoteCommand) {
|
|
const command = JSON.parse(remoteCommand) as string[];
|
|
const operation = command[command.indexOf("unidesk-file-transfer") + 1];
|
|
if (operation === "stat") return { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" };
|
|
if (operation === "write-b64-block-stdin") {
|
|
blocks += 1;
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
await Bun.sleep(10);
|
|
active -= 1;
|
|
}
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
},
|
|
};
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", source, "/tmp/payload.bin"]);
|
|
try {
|
|
expect(await runSshFileTransferOperation(
|
|
invocation,
|
|
["upload", source, "/tmp/payload.bin"],
|
|
executor,
|
|
builders,
|
|
)).toBe(0);
|
|
expect(blocks).toBe(9);
|
|
expect(maxActive).toBe(Math.min(readFileTransferConcurrency(), blocks));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("allows an explicit generated-text override without bypassing verification", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-generated-transfer-"));
|
|
const source = join(dir, "result.json");
|
|
const content = Buffer.from("{\"ok\":true}\n", "utf8");
|
|
writeFileSync(source, content);
|
|
const fake = fakeTransfer(content);
|
|
const invocation = parseSshInvocation("D601:/tmp", ["upload", "--allow-text-transfer", source, "/tmp/result.json"]);
|
|
try {
|
|
expect(await runSshFileTransferOperation(
|
|
invocation,
|
|
["upload", "--allow-text-transfer", source, "/tmp/result.json"],
|
|
fake.executor,
|
|
fake.builders,
|
|
)).toBe(0);
|
|
expect(fake.calls).toEqual(["write-b64-argv", "stat"]);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("streams Windows downloads through native PowerShell without a WSL mount path", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-win-download-"));
|
|
const target = join(dir, "capture.png");
|
|
const content = Buffer.from([137, 80, 78, 71, 0, 255]);
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
const commands: string[] = [];
|
|
const invocation = parseSshInvocation("G14-win:win/c/Users/lyon/.unidesk", [
|
|
"download",
|
|
"capture.png",
|
|
target,
|
|
]);
|
|
const builders: SshFileTransferCommandBuilders = {
|
|
buildRouteCommand(_route, command) {
|
|
return JSON.stringify(command);
|
|
},
|
|
buildWindowsPowerShellCommand(script) {
|
|
return script;
|
|
},
|
|
};
|
|
const executor: SshRemoteCommandExecutor = {
|
|
streamInactivityTimeoutMs: 60_000,
|
|
async runRemoteCommand(remoteCommand) {
|
|
commands.push(remoteCommand);
|
|
return { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" };
|
|
},
|
|
async streamRemoteCommand(remoteCommand, handlers, _input, options) {
|
|
commands.push(remoteCommand);
|
|
expect(options?.inactivityTimeoutMs).toBe(60_000);
|
|
await handlers.onStdout(content);
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
},
|
|
};
|
|
try {
|
|
expect(await runSshFileTransferOperation(
|
|
invocation,
|
|
["download", "capture.png", target],
|
|
executor,
|
|
builders,
|
|
)).toBe(0);
|
|
expect(commands.some((command) => command.includes("[Console]::OpenStandardOutput"))).toBe(true);
|
|
expect(commands.every((command) => !command.includes("/mnt/"))).toBe(true);
|
|
expect(Buffer.from(await Bun.file(target).arrayBuffer())).toEqual(content);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("downloads configured ranges concurrently and reconstructs the original file", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-parallel-download-"));
|
|
const target = join(dir, "payload.bin");
|
|
const content = Buffer.alloc(16 * 1024 * 1024, 0x3c);
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
let ranges = 0;
|
|
const invocation = parseSshInvocation("G14-win:win/c/Users/lyon", [
|
|
"download",
|
|
".unidesk/stress/payload.bin",
|
|
target,
|
|
]);
|
|
const builders: SshFileTransferCommandBuilders = {
|
|
buildRouteCommand(_route, command) {
|
|
return JSON.stringify(command);
|
|
},
|
|
buildWindowsPowerShellCommand(script) {
|
|
return script;
|
|
},
|
|
};
|
|
const executor: SshRemoteCommandExecutor = {
|
|
streamInactivityTimeoutMs: 60_000,
|
|
async runRemoteCommand() {
|
|
return { exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" };
|
|
},
|
|
async streamRemoteCommand(remoteCommand, handlers) {
|
|
const offset = Number(remoteCommand.match(/\$offset = \[Int64\](\d+);/u)?.[1]);
|
|
const length = Number(remoteCommand.match(/\$length = \[Int64\](\d+);/u)?.[1]);
|
|
const firstLength = Math.floor(length / 2);
|
|
ranges += 1;
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
await Bun.sleep(10);
|
|
await Promise.all([
|
|
handlers.onStdout(content.subarray(offset, offset + firstLength)),
|
|
handlers.onStdout(content.subarray(offset + firstLength, offset + length)),
|
|
]);
|
|
active -= 1;
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
},
|
|
};
|
|
try {
|
|
expect(await runSshFileTransferOperation(
|
|
invocation,
|
|
["download", ".unidesk/stress/payload.bin", target],
|
|
executor,
|
|
builders,
|
|
)).toBe(0);
|
|
const expectedConcurrency = Math.min(readFileTransferConcurrency(), content.length / (1024 * 1024));
|
|
expect(ranges).toBe(expectedConcurrency);
|
|
expect(maxActive).toBe(expectedConcurrency);
|
|
expect(Buffer.from(await Bun.file(target).arrayBuffer())).toEqual(content);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("retries a download range only when transport fails before the first byte", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "unidesk-download-open-retry-"));
|
|
const target = join(dir, "payload.bin");
|
|
const content = Buffer.alloc(1024 * 1024, 0x7c);
|
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
let streamAttempts = 0;
|
|
const invocation = parseSshInvocation("D601:/tmp", ["download", "/tmp/payload.bin", target]);
|
|
const fake = fakeTransfer(content);
|
|
fake.executor.runRemoteCommand = async () => ({ exitCode: 0, stdout: `${content.length} ${sha256}\n`, stderr: "" });
|
|
fake.executor.streamRemoteCommand = async (_command, handlers) => {
|
|
streamAttempts += 1;
|
|
if (streamAttempts === 1) {
|
|
return { exitCode: 255, stdout: "", stderr: "unidesk remote frontend ssh bridge timed out waiting for provider session\n" };
|
|
}
|
|
await handlers.onStdout(content);
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
};
|
|
try {
|
|
expect(await runSshFileTransferOperation(invocation, ["download", "/tmp/payload.bin", target], fake.executor, fake.builders)).toBe(0);
|
|
expect(streamAttempts).toBe(2);
|
|
expect(Buffer.from(await Bun.file(target).arrayBuffer())).toEqual(content);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|