670 lines
30 KiB
TypeScript
670 lines
30 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { once } from "node:events";
|
|
import { createWriteStream } from "node:fs";
|
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { ParsedSshInvocation, ParsedSshRoute, SshCaptureResult } from "./ssh";
|
|
|
|
export interface SshRemoteCommandStreamHandlers {
|
|
onStdout(chunk: Buffer): Promise<void> | void;
|
|
onStderr?(chunk: Buffer): Promise<void> | void;
|
|
}
|
|
|
|
export interface SshRemoteCommandExecutor {
|
|
runRemoteCommand(remoteCommand: string, input?: string): Promise<SshCaptureResult>;
|
|
streamRemoteCommand?(
|
|
remoteCommand: string,
|
|
handlers: SshRemoteCommandStreamHandlers,
|
|
input?: string,
|
|
options?: { inactivityTimeoutMs?: number },
|
|
): Promise<SshCaptureResult>;
|
|
}
|
|
|
|
export interface SshFileTransferCommandBuilders {
|
|
buildRouteCommand(route: ParsedSshRoute, command: string[], options?: { stdin?: boolean }): string;
|
|
buildWindowsPowerShellCommand(script: string): string;
|
|
}
|
|
|
|
type SshFileTransferOperation =
|
|
| "stat"
|
|
| "write-b64-argv"
|
|
| "write-b64-stdin"
|
|
| "write-b64-begin"
|
|
| "write-b64-append-stdin"
|
|
| "write-b64-commit";
|
|
|
|
interface SshFileTransferCliOptions {
|
|
action: "upload" | "download";
|
|
localPath: string;
|
|
remotePath: string;
|
|
inactivityTimeoutMs?: number;
|
|
}
|
|
|
|
interface SshFileTransferStat {
|
|
bytes: number;
|
|
sha256: string;
|
|
}
|
|
|
|
interface SshFileTransferEndpointStat extends SshFileTransferStat {
|
|
side: "local" | "remote";
|
|
path: string;
|
|
}
|
|
|
|
interface SshFileTransferWriteResult {
|
|
strategy: "argv" | "stdin" | "chunked-stdin";
|
|
chunks: number;
|
|
}
|
|
|
|
interface SshFileTransferDownloadResult {
|
|
remote: SshFileTransferStat;
|
|
local: SshFileTransferStat;
|
|
strategy: "tcp-pool-stdout-stream";
|
|
transport: "host.ssh.tcp-pool";
|
|
chunks: number;
|
|
elapsedMs: number;
|
|
throughputBytesPerSecond: number;
|
|
remainingBytes: number;
|
|
}
|
|
|
|
export interface SshVerifiedDownloadResult {
|
|
remotePath: string;
|
|
localPath: string;
|
|
bytes: number;
|
|
sha256: string;
|
|
verified: true;
|
|
verification: Record<string, unknown>;
|
|
transfer: {
|
|
strategy: SshFileTransferDownloadResult["strategy"];
|
|
transport: SshFileTransferDownloadResult["transport"];
|
|
chunks: number;
|
|
elapsedMs: number;
|
|
throughputBytesPerSecond: number;
|
|
remainingBytes: number;
|
|
};
|
|
}
|
|
|
|
class SshFileTransferError extends Error {
|
|
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
|
|
super(message);
|
|
this.name = "SshFileTransferError";
|
|
}
|
|
}
|
|
|
|
const fileTransferWriteB64ArgvLimit = 48_000;
|
|
const fileTransferWriteRawChunkBytes = 131_072;
|
|
const fileTransferWriteB64ChunkChars = 1_398_104;
|
|
const fileTransferProgressEveryChunks = 16;
|
|
|
|
export function isSshFileTransferOperation(args: string[]): boolean {
|
|
const subcommand = args[0] ?? "";
|
|
return subcommand === "upload" || subcommand === "download";
|
|
}
|
|
|
|
export async function runSshFileTransferOperation(
|
|
invocation: ParsedSshInvocation,
|
|
args: string[],
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
): Promise<number> {
|
|
const options = parseSshFileTransferCliOptions(args);
|
|
const localPath = path.resolve(options.localPath);
|
|
if (options.action === "upload") {
|
|
const content = await readFile(localPath);
|
|
const expected = { bytes: content.length, sha256: sha256HexBuffer(content) };
|
|
const write = await writeRemoteFileVerified(invocation, executor, builders, options.remotePath, content);
|
|
const remote = await statRemoteFile(invocation, executor, builders, options.remotePath);
|
|
assertTransferStat("upload final remote verification", options.remotePath, expected, remote);
|
|
const verification = buildTransferVerification(
|
|
{ side: "local", path: localPath, ...expected },
|
|
{ side: "remote", path: options.remotePath, ...remote },
|
|
);
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
command: "ssh upload",
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
localPath,
|
|
remotePath: options.remotePath,
|
|
bytes: expected.bytes,
|
|
sha256: expected.sha256,
|
|
verified: true,
|
|
verification,
|
|
transfer: write,
|
|
}, null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
|
|
const download = await downloadSshFileVerified(invocation, executor, builders, options.remotePath, localPath, options.inactivityTimeoutMs);
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
command: "ssh download",
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath: options.remotePath,
|
|
localPath,
|
|
bytes: download.bytes,
|
|
sha256: download.sha256,
|
|
verified: true,
|
|
verification: download.verification,
|
|
transfer: download.transfer,
|
|
}, null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
|
|
export async function downloadSshFileVerified(
|
|
invocation: ParsedSshInvocation,
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
remotePath: string,
|
|
localPath: string,
|
|
inactivityTimeoutMs?: number,
|
|
): Promise<SshVerifiedDownloadResult> {
|
|
const read = await downloadRemoteFileVerified(invocation, executor, builders, remotePath, localPath, inactivityTimeoutMs);
|
|
const verification = buildTransferVerification(
|
|
{ side: "remote", path: remotePath, ...read.remote },
|
|
{ side: "local", path: localPath, ...read.local },
|
|
);
|
|
return {
|
|
remotePath,
|
|
localPath,
|
|
bytes: read.remote.bytes,
|
|
sha256: read.remote.sha256,
|
|
verified: true,
|
|
verification,
|
|
transfer: {
|
|
strategy: read.strategy,
|
|
transport: read.transport,
|
|
chunks: read.chunks,
|
|
elapsedMs: read.elapsedMs,
|
|
throughputBytesPerSecond: read.throughputBytesPerSecond,
|
|
remainingBytes: read.remainingBytes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseSshFileTransferCliOptions(args: string[]): SshFileTransferCliOptions {
|
|
const action = args[0];
|
|
if (action !== "upload" && action !== "download") throw new Error("ssh file transfer requires upload or download");
|
|
const positionals: string[] = [];
|
|
let inactivityTimeoutMs: number | undefined;
|
|
for (let index = 1; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--") {
|
|
positionals.push(...args.slice(index + 1));
|
|
break;
|
|
}
|
|
if (arg === "--chunk-bytes" || arg === "--block-bytes") {
|
|
throw new Error(`unsupported ssh ${action} option: ${arg}; downloads use host.ssh.tcp-pool stdout streaming and no longer support the legacy base64 block reader`);
|
|
}
|
|
if (arg === "--inactivity-timeout-ms" || arg === "--runtime-timeout-ms") {
|
|
const value = args[index + 1];
|
|
if (value === undefined) throw new Error(`ssh ${action} ${arg} requires a positive integer value`);
|
|
index += 1;
|
|
inactivityTimeoutMs = parsePositiveRuntimeTimeoutMs(value, `ssh ${action} ${arg}`);
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--inactivity-timeout-ms=")) {
|
|
inactivityTimeoutMs = parsePositiveRuntimeTimeoutMs(arg.slice("--inactivity-timeout-ms=".length), `ssh ${action} --inactivity-timeout-ms`);
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--runtime-timeout-ms=")) {
|
|
inactivityTimeoutMs = parsePositiveRuntimeTimeoutMs(arg.slice("--runtime-timeout-ms=".length), `ssh ${action} --runtime-timeout-ms`);
|
|
continue;
|
|
}
|
|
if (arg === "--help" || arg === "-h" || arg === "help") {
|
|
throw new Error(`ssh ${action} usage: trans <route> ${action} ${action === "upload" ? "<local-file> <remote-file>" : "<remote-file> <local-file>"}`);
|
|
}
|
|
if (arg.startsWith("-")) throw new Error(`unsupported ssh ${action} option: ${arg}`);
|
|
positionals.push(arg);
|
|
}
|
|
if (positionals.length !== 2) {
|
|
throw new Error(`ssh ${action} requires exactly two paths: ${action === "upload" ? "<local-file> <remote-file>" : "<remote-file> <local-file>"}`);
|
|
}
|
|
const [first, second] = positionals;
|
|
if (!first || !second) throw new Error(`ssh ${action} paths must be non-empty`);
|
|
return action === "upload"
|
|
? { action, localPath: first, remotePath: second, inactivityTimeoutMs }
|
|
: { action, remotePath: first, localPath: second, inactivityTimeoutMs };
|
|
}
|
|
|
|
function parsePositiveRuntimeTimeoutMs(value: string, label: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
|
|
throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function writeRemoteFileVerified(
|
|
invocation: ParsedSshInvocation,
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
remotePath: string,
|
|
content: Buffer,
|
|
): Promise<SshFileTransferWriteResult> {
|
|
const expectedBytes = String(content.length);
|
|
const expectedSha256 = sha256HexBuffer(content);
|
|
const encoded = content.length <= fileTransferWriteB64ArgvLimit
|
|
? content.toString("base64")
|
|
: "";
|
|
if (invocation.route.plane !== "win" && encoded.length > 0 && encoded.length <= fileTransferWriteB64ArgvLimit) {
|
|
await checkedFileTransfer(invocation, executor, builders, "write-b64-argv", [remotePath, expectedBytes, expectedSha256, ...chunkString(encoded, fileTransferWriteB64ChunkChars)]);
|
|
return { strategy: "argv", chunks: encoded.length === 0 ? 0 : Math.ceil(encoded.length / fileTransferWriteB64ChunkChars) };
|
|
}
|
|
if (content.length <= fileTransferWriteRawChunkBytes) {
|
|
try {
|
|
await checkedFileTransfer(invocation, executor, builders, "write-b64-stdin", [remotePath, expectedBytes, expectedSha256], content.toString("base64"));
|
|
return { strategy: "stdin", chunks: 1 };
|
|
} catch {
|
|
// Fall through to chunked upload with per-block base64 encoding.
|
|
}
|
|
}
|
|
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
|
const startedAtMs = Date.now();
|
|
let chunks = 0;
|
|
await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]);
|
|
for (let offset = 0; offset < content.length; offset += fileTransferWriteRawChunkBytes) {
|
|
const nextOffset = Math.min(content.length, offset + fileTransferWriteRawChunkBytes);
|
|
const encodedChunk = content.subarray(offset, nextOffset).toString("base64");
|
|
await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], encodedChunk);
|
|
chunks += 1;
|
|
emitUploadProgress(invocation, remotePath, chunks, nextOffset, content.length, nextOffset - offset, startedAtMs);
|
|
}
|
|
await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]);
|
|
emitUploadProgress(invocation, remotePath, Math.max(1, chunks), content.length, content.length, 0, startedAtMs, true);
|
|
return { strategy: "chunked-stdin", chunks };
|
|
}
|
|
|
|
async function downloadRemoteFileVerified(
|
|
invocation: ParsedSshInvocation,
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
remotePath: string,
|
|
localPath: string,
|
|
inactivityTimeoutMs?: number,
|
|
): Promise<SshFileTransferDownloadResult> {
|
|
if (executor.streamRemoteCommand === undefined) {
|
|
throw new SshFileTransferError("ssh download requires a tcp-pool stdout stream sink", {
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath,
|
|
localPath,
|
|
requiredTransport: "host.ssh.tcp-pool",
|
|
blocker: "streamRemoteCommand unavailable",
|
|
});
|
|
}
|
|
if (invocation.route.plane === "win") {
|
|
throw new SshFileTransferError("ssh download requires tcp-pool stdout streaming; win routes are not supported by the safe binary stream path", {
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath,
|
|
localPath,
|
|
requiredTransport: "host.ssh.tcp-pool",
|
|
blocker: "win route has no verified binary stdout stream path",
|
|
});
|
|
}
|
|
const remote = await statRemoteFile(invocation, executor, builders, remotePath);
|
|
await mkdir(path.dirname(localPath), { recursive: true });
|
|
const remoteCommand = buildStreamDownloadRemoteCommand(invocation.route, builders, remotePath);
|
|
const startedAtMs = Date.now();
|
|
const hash = createHash("sha256");
|
|
const output = createWriteStream(localPath, { flags: "w", mode: 0o600 });
|
|
let actualBytes = 0;
|
|
let chunkCount = 0;
|
|
try {
|
|
const result = await executor.streamRemoteCommand(remoteCommand, {
|
|
onStdout: async (chunk) => {
|
|
await writeStreamChunk(output, chunk);
|
|
actualBytes += chunk.length;
|
|
chunkCount += 1;
|
|
hash.update(chunk);
|
|
emitDownloadProgress(invocation, remotePath, chunkCount, actualBytes, remote.bytes, chunk.length, startedAtMs);
|
|
},
|
|
}, undefined, { inactivityTimeoutMs });
|
|
await closeWriteStream(output);
|
|
if (result.exitCode !== 0) {
|
|
throw new SshFileTransferError("remote ssh download stream failed", {
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath,
|
|
localPath,
|
|
requiredTransport: "host.ssh.tcp-pool",
|
|
exitCode: result.exitCode,
|
|
stdout: transferTextSnapshot(result.stdout, { headChars: 120, tailChars: 500 }),
|
|
stderr: transferTextSnapshot(result.stderr, { headChars: 120, tailChars: 1000 }),
|
|
});
|
|
}
|
|
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
|
|
const local = { bytes: actualBytes, sha256: hash.digest("hex") };
|
|
assertTransferStat("download tcp-pool stdout stream verification", localPath, remote, local);
|
|
emitDownloadProgress(invocation, remotePath, Math.max(1, chunkCount), actualBytes, remote.bytes, 0, startedAtMs, true);
|
|
return {
|
|
remote,
|
|
local,
|
|
strategy: "tcp-pool-stdout-stream",
|
|
transport: "host.ssh.tcp-pool",
|
|
chunks: chunkCount,
|
|
elapsedMs,
|
|
throughputBytesPerSecond: Math.round((actualBytes * 1000) / elapsedMs),
|
|
remainingBytes: Math.max(0, remote.bytes - actualBytes),
|
|
};
|
|
} catch (error) {
|
|
output.destroy();
|
|
await rm(localPath, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function emitDownloadProgress(
|
|
invocation: ParsedSshInvocation,
|
|
remotePath: string,
|
|
chunkCount: number,
|
|
actualBytes: number,
|
|
expectedBytes: number,
|
|
lastChunkBytes: number,
|
|
startedAtMs: number,
|
|
force = false,
|
|
): void {
|
|
if (!force && chunkCount !== 1 && actualBytes < expectedBytes && chunkCount % fileTransferProgressEveryChunks !== 0) return;
|
|
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
|
|
process.stderr.write(`${JSON.stringify({
|
|
event: "unidesk.ssh.download.progress",
|
|
at: new Date().toISOString(),
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath,
|
|
strategy: "tcp-pool-stdout-stream",
|
|
transport: "host.ssh.tcp-pool",
|
|
chunks: chunkCount,
|
|
bytes: actualBytes,
|
|
totalBytes: expectedBytes,
|
|
actualBytes,
|
|
expectedBytes,
|
|
lastChunkBytes,
|
|
remainingBytes: Math.max(0, expectedBytes - actualBytes),
|
|
elapsedMs,
|
|
throughputBytesPerSecond: Math.round((actualBytes * 1000) / elapsedMs),
|
|
})}\n`);
|
|
}
|
|
|
|
function emitUploadProgress(
|
|
invocation: ParsedSshInvocation,
|
|
remotePath: string,
|
|
chunkCount: number,
|
|
actualBytes: number,
|
|
expectedBytes: number,
|
|
lastChunkBytes: number,
|
|
startedAtMs: number,
|
|
force = false,
|
|
): void {
|
|
if (!force && chunkCount !== 1 && actualBytes < expectedBytes && chunkCount % fileTransferProgressEveryChunks !== 0) return;
|
|
const elapsedMs = Math.max(1, Date.now() - startedAtMs);
|
|
process.stderr.write(`${JSON.stringify({
|
|
event: "unidesk.ssh.upload.progress",
|
|
at: new Date().toISOString(),
|
|
route: invocation.route.raw,
|
|
providerId: invocation.providerId,
|
|
remotePath,
|
|
strategy: "chunked-stdin",
|
|
chunks: chunkCount,
|
|
bytes: actualBytes,
|
|
totalBytes: expectedBytes,
|
|
actualBytes,
|
|
expectedBytes,
|
|
lastChunkBytes,
|
|
remainingBytes: Math.max(0, expectedBytes - actualBytes),
|
|
elapsedMs,
|
|
throughputBytesPerSecond: Math.round((actualBytes * 1000) / elapsedMs),
|
|
})}\n`);
|
|
}
|
|
|
|
async function statRemoteFile(
|
|
invocation: ParsedSshInvocation,
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
remotePath: string,
|
|
): Promise<SshFileTransferStat> {
|
|
const stat = await checkedFileTransfer(invocation, executor, builders, "stat", [remotePath]);
|
|
return parseFileTransferStat(stat.stdout, stat.stderr, remotePath);
|
|
}
|
|
|
|
function buildStreamDownloadRemoteCommand(
|
|
route: ParsedSshRoute,
|
|
builders: SshFileTransferCommandBuilders,
|
|
remotePath: string,
|
|
): string {
|
|
return builders.buildRouteCommand(route, ["sh", "-c", "cat -- \"$1\"", "unidesk-download", remotePath]);
|
|
}
|
|
|
|
async function writeStreamChunk(stream: ReturnType<typeof createWriteStream>, chunk: Buffer): Promise<void> {
|
|
if (chunk.length === 0) return;
|
|
await new Promise<void>((resolve, reject) => {
|
|
stream.write(chunk, (error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function closeWriteStream(stream: ReturnType<typeof createWriteStream>): Promise<void> {
|
|
if (stream.closed) return;
|
|
const closed = once(stream, "close");
|
|
stream.end();
|
|
await closed;
|
|
}
|
|
|
|
async function checkedFileTransfer(
|
|
invocation: ParsedSshInvocation,
|
|
executor: SshRemoteCommandExecutor,
|
|
builders: SshFileTransferCommandBuilders,
|
|
operation: SshFileTransferOperation,
|
|
args: string[],
|
|
input?: string,
|
|
): Promise<SshCaptureResult> {
|
|
const remoteCommand = buildFileTransferRemoteCommand(invocation.route, builders, operation, args, input !== undefined);
|
|
const result = await executor.runRemoteCommand(remoteCommand, input);
|
|
if (result.exitCode === 0) return result;
|
|
throw new SshFileTransferError("remote ssh file transfer operation failed", {
|
|
route: invocation.route.raw,
|
|
operation,
|
|
args: args.slice(0, 4),
|
|
exitCode: result.exitCode,
|
|
stdout: transferTextSnapshot(result.stdout, { headChars: operation === "stat" ? 500 : 120, tailChars: 500 }),
|
|
stderr: transferTextSnapshot(result.stderr, { headChars: 120, tailChars: 1000 }),
|
|
});
|
|
}
|
|
|
|
function buildFileTransferRemoteCommand(
|
|
route: ParsedSshRoute,
|
|
builders: SshFileTransferCommandBuilders,
|
|
operation: SshFileTransferOperation,
|
|
args: string[],
|
|
hasInput: boolean,
|
|
): string {
|
|
if (route.plane === "win") return buildWindowsFileTransferCommand(route, builders, operation, args);
|
|
return builders.buildRouteCommand(route, ["sh", "-c", posixFileTransferScript(), "unidesk-file-transfer", operation, ...args], { stdin: hasInput });
|
|
}
|
|
|
|
function parseFileTransferStat(stdout: string, stderr: string, remotePath: string): SshFileTransferStat {
|
|
const lines = stdout.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
for (const line of lines) {
|
|
const [bytesText, sha256, extra] = line.split(/\s+/u);
|
|
const bytes = Number(bytesText);
|
|
if (extra === undefined && Number.isSafeInteger(bytes) && bytes >= 0 && /^[0-9a-f]{64}$/u.test(sha256 ?? "")) {
|
|
return { bytes, sha256: sha256! };
|
|
}
|
|
}
|
|
{
|
|
throw new SshFileTransferError("remote file transfer stat returned invalid metadata", {
|
|
remotePath,
|
|
expected: "<bytes> <sha256>",
|
|
stdout: transferTextSnapshot(stdout, { headChars: 500, tailChars: 500 }),
|
|
stderr: transferTextSnapshot(stderr, { headChars: 120, tailChars: 1000 }),
|
|
candidateLines: lines.slice(0, 8).map((line) => line.slice(0, 200)),
|
|
candidateLineCount: lines.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
function transferTextSnapshot(value: string, limits: { headChars: number; tailChars: number }): Record<string, unknown> {
|
|
return {
|
|
bytes: Buffer.byteLength(value, "utf8"),
|
|
chars: value.length,
|
|
head: value.slice(0, limits.headChars),
|
|
tail: value.slice(Math.max(0, value.length - limits.tailChars)),
|
|
truncated: value.length > limits.headChars + limits.tailChars,
|
|
};
|
|
}
|
|
|
|
function assertTransferStat(label: string, pathName: string, expected: SshFileTransferStat, actual: SshFileTransferStat): void {
|
|
if (expected.bytes === actual.bytes && expected.sha256 === actual.sha256) return;
|
|
throw new SshFileTransferError(`${label} failed`, {
|
|
path: pathName,
|
|
expected,
|
|
actual,
|
|
});
|
|
}
|
|
|
|
function buildTransferVerification(source: SshFileTransferEndpointStat, target: SshFileTransferEndpointStat): Record<string, unknown> {
|
|
return {
|
|
automatic: true,
|
|
algorithm: "sha256",
|
|
checked: ["bytes", "sha256"],
|
|
verified: source.bytes === target.bytes && source.sha256 === target.sha256,
|
|
source,
|
|
target,
|
|
match: {
|
|
bytes: source.bytes === target.bytes,
|
|
sha256: source.sha256 === target.sha256,
|
|
},
|
|
};
|
|
}
|
|
|
|
function sha256HexBuffer(value: Buffer): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function chunkString(value: string, chunkSize: number): string[] {
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += chunkSize) {
|
|
chunks.push(value.slice(index, index + chunkSize));
|
|
}
|
|
return chunks.length > 0 ? chunks : [""];
|
|
}
|
|
|
|
function posixFileTransferScript(): string {
|
|
return [
|
|
"set -eu",
|
|
"sha256_file() {",
|
|
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
|
" printf 'missing sha256 tool\\n' >&2; return 127",
|
|
"}",
|
|
"ensure_parent() { case \"$1\" in */*) parent=${1%/*}; mkdir -p -- \"$parent\";; esac; }",
|
|
"verify_tmp() {",
|
|
" target=$1; tmp=$2; expected_bytes=$3; expected_sha256=$4",
|
|
" actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
|
|
" if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then rm -f -- \"$tmp\"; printf 'transfer byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2; exit 23; fi",
|
|
" actual_sha256=$(sha256_file \"$tmp\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then rm -f -- \"$tmp\"; printf 'transfer sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2; exit 24; fi",
|
|
"}",
|
|
"set_tmp_paths() {",
|
|
" target=$1; token=$2",
|
|
" case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid transfer temp token\\n' >&2; exit 2;; esac",
|
|
" base=${target##*/}; dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-transfer-${token}.tmp\"",
|
|
" tmp_b64=\"$tmp.b64\"",
|
|
"}",
|
|
"op=$1; shift",
|
|
"case \"$op\" in",
|
|
" stat)",
|
|
" target=$1; bytes=$(wc -c < \"$target\" | tr -d '[:space:]'); digest=$(sha256_file \"$target\"); printf '%s %s\\n' \"$bytes\" \"$digest\"",
|
|
" ;;",
|
|
" write-b64-argv)",
|
|
" target=$1; expected_bytes=$2; expected_sha256=$3; shift 3",
|
|
" ensure_parent \"$target\"; base=${target##*/}; dir=.; case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-transfer-$$.tmp\"; tmp_b64=\"$tmp.b64\"; : > \"$tmp_b64\"",
|
|
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp_b64\"; done",
|
|
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
|
" rm -f -- \"$tmp_b64\"; verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" write-b64-stdin)",
|
|
" target=$1; expected_bytes=$2; expected_sha256=$3",
|
|
" ensure_parent \"$target\"; base=${target##*/}; dir=.; case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-transfer-$$.tmp\"",
|
|
" if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" write-b64-begin)",
|
|
" target=$1; token=$2; ensure_parent \"$target\"; set_tmp_paths \"$target\" \"$token\"; : > \"$tmp_b64\"",
|
|
" ;;",
|
|
" write-b64-append-stdin)",
|
|
" target=$1; token=$2; set_tmp_paths \"$target\" \"$token\"; cat >> \"$tmp_b64\"",
|
|
" ;;",
|
|
" write-b64-commit)",
|
|
" target=$1; token=$2; expected_bytes=$3; expected_sha256=$4; set_tmp_paths \"$target\" \"$token\"",
|
|
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'transfer base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
|
" rm -f -- \"$tmp_b64\"; verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"; mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\"); if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'transfer final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" *) printf 'unsupported transfer op: %s\\n' \"$op\" >&2; exit 2;;",
|
|
"esac",
|
|
].join("\n");
|
|
}
|
|
|
|
function buildWindowsFileTransferCommand(
|
|
route: ParsedSshRoute,
|
|
builders: SshFileTransferCommandBuilders,
|
|
operation: SshFileTransferOperation,
|
|
args: string[],
|
|
): string {
|
|
return builders.buildWindowsPowerShellCommand(windowsFileTransferScript(route.workspace, operation, args));
|
|
}
|
|
|
|
function windowsFileTransferScript(basePath: string | null, operation: SshFileTransferOperation, args: string[]): string {
|
|
const target = args[0] ?? "";
|
|
const tokenOrBlock = args[1] ?? "";
|
|
const third = args[2] ?? "";
|
|
const fourth = args[3] ?? "";
|
|
const argvChunks = args.slice(3).map(powerShellSingleQuote).join(", ");
|
|
return [
|
|
"$ErrorActionPreference = 'Stop';",
|
|
"$ProgressPreference = 'SilentlyContinue';",
|
|
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
"$OutputEncoding = [System.Text.UTF8Encoding]::new();",
|
|
`$basePath = ${powerShellSingleQuote(basePath ?? "")};`,
|
|
`$operation = ${powerShellSingleQuote(operation)};`,
|
|
`$targetArg = ${powerShellSingleQuote(target)};`,
|
|
`$arg1 = ${powerShellSingleQuote(tokenOrBlock)};`,
|
|
`$arg2 = ${powerShellSingleQuote(third)};`,
|
|
`$arg3 = ${powerShellSingleQuote(fourth)};`,
|
|
`$argvChunks = @(${argvChunks});`,
|
|
"function Fail([string]$Message, [int]$Code) { [Console]::Error.WriteLine($Message); exit $Code }",
|
|
"function Resolve-UnideskPath([string]$Raw) { if ([string]::IsNullOrWhiteSpace($Raw)) { Fail 'empty transfer path' 2 }; if ([System.IO.Path]::IsPathRooted($Raw)) { return [System.IO.Path]::GetFullPath($Raw) }; if (-not [string]::IsNullOrWhiteSpace($basePath)) { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($basePath, $Raw)) }; return [System.IO.Path]::GetFullPath($Raw) }",
|
|
"function Ensure-Parent([string]$Target) { $parent = [System.IO.Path]::GetDirectoryName($Target); if (-not [string]::IsNullOrWhiteSpace($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null } }",
|
|
"function Get-Sha256([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }",
|
|
"function Set-TmpPaths([string]$Target, [string]$Token) { if ($Token -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'invalid transfer temp token' 2 }; $dir = [System.IO.Path]::GetDirectoryName($Target); if ([string]::IsNullOrWhiteSpace($dir)) { $dir = (Get-Location).ProviderPath }; $base = [System.IO.Path]::GetFileName($Target); $script:tmp = [System.IO.Path]::Combine($dir, '.' + $base + '.unidesk-transfer-' + $Token + '.tmp'); $script:tmpB64 = $script:tmp + '.b64' }",
|
|
"function Verify-Temp([string]$Target, [string]$Tmp, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { $actualBytes = ([System.IO.FileInfo]$Tmp).Length; if ($actualBytes -ne $ExpectedBytes) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('transfer byte count mismatch for ' + $Target + ': expected=' + $ExpectedBytes + ' actual=' + $actualBytes) 23 }; $actualSha256 = Get-Sha256 $Tmp; if ($actualSha256 -ne $ExpectedSha256) { Remove-Item -LiteralPath $Tmp -Force -ErrorAction SilentlyContinue; Fail ('transfer sha256 mismatch for ' + $Target + ': expected=' + $ExpectedSha256 + ' actual=' + $actualSha256) 24 } }",
|
|
"function Decode-ToTarget([string]$Target, [string]$Encoded, [Int64]$ExpectedBytes, [string]$ExpectedSha256) { Ensure-Parent $Target; Set-TmpPaths $Target ([guid]::NewGuid().ToString('N')); try { $bytes = [Convert]::FromBase64String(($Encoded -replace '\\s','')) } catch { Fail ('transfer base64 decode failed for ' + $Target + ': ' + $_.Exception.Message) 22 }; [System.IO.File]::WriteAllBytes($script:tmp, $bytes); Verify-Temp $Target $script:tmp $ExpectedBytes $ExpectedSha256; Move-Item -LiteralPath $script:tmp -Destination $Target -Force; $actualSha256 = Get-Sha256 $Target; if ($actualSha256 -ne $ExpectedSha256) { Fail ('transfer final sha256 mismatch for ' + $Target) 25 } }",
|
|
"$target = Resolve-UnideskPath $targetArg;",
|
|
"switch ($operation) {",
|
|
" 'stat' { if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { Fail ('file not found: ' + $target) 1 }; $bytes = ([System.IO.FileInfo]$target).Length; $digest = Get-Sha256 $target; [Console]::Out.WriteLine(([string]$bytes) + ' ' + $digest); break }",
|
|
" 'write-b64-argv' { Decode-ToTarget $target ([string]::Concat($argvChunks)) ([Int64]$arg1) $arg2; break }",
|
|
" 'write-b64-stdin' { Decode-ToTarget $target ([Console]::In.ReadToEnd()) ([Int64]$arg1) $arg2; break }",
|
|
" 'write-b64-begin' { Ensure-Parent $target; Set-TmpPaths $target $arg1; [System.IO.File]::WriteAllText($script:tmpB64, '', [System.Text.Encoding]::ASCII); break }",
|
|
" 'write-b64-append-stdin' { Set-TmpPaths $target $arg1; $chunk = ([Console]::In.ReadToEnd()) -replace '\\s',''; [System.IO.File]::AppendAllText($script:tmpB64, $chunk, [System.Text.Encoding]::ASCII); break }",
|
|
" 'write-b64-commit' { Set-TmpPaths $target $arg1; $encoded = [System.IO.File]::ReadAllText($script:tmpB64, [System.Text.Encoding]::ASCII); Remove-Item -LiteralPath $script:tmpB64 -Force -ErrorAction SilentlyContinue; Decode-ToTarget $target $encoded ([Int64]$arg2) $arg3; break }",
|
|
" default { Fail ('unsupported transfer op: ' + $operation) 2 }",
|
|
"}",
|
|
].join(" ");
|
|
}
|
|
|
|
function powerShellSingleQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|