import { createHash, randomBytes } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { ParsedSshInvocation, ParsedSshRoute, SshCaptureResult } from "./ssh"; export interface SshRemoteCommandExecutor { runRemoteCommand(remoteCommand: string, input?: string): Promise; } export interface SshFileTransferCommandBuilders { buildRouteCommand(route: ParsedSshRoute, command: string[], options?: { stdin?: boolean }): string; buildWindowsPowerShellCommand(script: string): string; } type SshFileTransferOperation = | "stat" | "read-b64-block" | "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; readBlockBytes: 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; } class SshFileTransferError extends Error { constructor(message: string, public readonly details: Record = {}) { super(message); this.name = "SshFileTransferError"; } } const fileTransferReadBlockBytes = 1_048_576; const fileTransferWriteB64ArgvLimit = 48_000; const fileTransferWriteRawChunkBytes = 1_048_576; const fileTransferWriteB64ChunkChars = 1_398_104; const fileTransferReadBlockMaxAttempts = 12; const fileTransferReadEmptyRetryDelayMs = 250; 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 { 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 read = await readRemoteFileVerified(invocation, executor, builders, options.remotePath, options.readBlockBytes); await mkdir(path.dirname(localPath), { recursive: true }); await writeFile(localPath, read.content); const local = await readFile(localPath); const localStat = { bytes: local.length, sha256: sha256HexBuffer(local) }; assertTransferStat("download final local verification", localPath, read.remote, localStat); const verification = buildTransferVerification( { side: "remote", path: options.remotePath, ...read.remote }, { side: "local", path: localPath, ...localStat }, ); process.stdout.write(`${JSON.stringify({ ok: true, command: "ssh download", route: invocation.route.raw, providerId: invocation.providerId, remotePath: options.remotePath, localPath, bytes: read.remote.bytes, sha256: read.remote.sha256, verified: true, verification, transfer: { strategy: "chunked-read", chunks: read.chunks, chunkBytes: options.readBlockBytes, }, }, null, 2)}\n`); return 0; } 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 readBlockBytes = fileTransferReadBlockBytes; 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") { const value = args[index + 1]; if (value === undefined) throw new Error(`ssh ${action} ${arg} requires a value`); readBlockBytes = boundedTransferChunkBytes(value, `ssh ${action} ${arg}`); index += 1; continue; } if (arg === "--help" || arg === "-h" || arg === "help") { throw new Error(`ssh ${action} usage: trans ${action} ${action === "upload" ? " " : " "} [--chunk-bytes N]`); } 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" ? " " : " "}`); } 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, readBlockBytes } : { action, remotePath: first, localPath: second, readBlockBytes }; } function boundedTransferChunkBytes(raw: string, option: string): number { const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${option} must be a positive integer`); return Math.min(4 * 1024 * 1024, Math.max(1024, value)); } async function writeRemoteFileVerified( invocation: ParsedSshInvocation, executor: SshRemoteCommandExecutor, builders: SshFileTransferCommandBuilders, remotePath: string, content: Buffer, ): Promise { const expectedBytes = String(content.length); const expectedSha256 = sha256HexBuffer(content); const encoded = content.length <= fileTransferWriteB64ArgvLimit ? content.toString("base64") : ""; if (invocation.route.plane !== "win" && 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)}`; let chunks = 0; await checkedFileTransfer(invocation, executor, builders, "write-b64-begin", [remotePath, token]); for (let offset = 0; offset < content.length; offset += fileTransferWriteRawChunkBytes) { const encodedChunk = content.subarray(offset, Math.min(content.length, offset + fileTransferWriteRawChunkBytes)).toString("base64"); await checkedFileTransfer(invocation, executor, builders, "write-b64-append-stdin", [remotePath, token], encodedChunk); chunks += 1; } await checkedFileTransfer(invocation, executor, builders, "write-b64-commit", [remotePath, token, expectedBytes, expectedSha256]); return { strategy: "chunked-stdin", chunks }; } async function readRemoteFileVerified( invocation: ParsedSshInvocation, executor: SshRemoteCommandExecutor, builders: SshFileTransferCommandBuilders, remotePath: string, readBlockBytes: number, ): Promise<{ remote: SshFileTransferStat; content: Buffer; chunks: number }> { const remote = await statRemoteFile(invocation, executor, builders, remotePath); const chunks: Buffer[] = []; let actualBytes = 0; let chunkCount = 0; for (let blockIndex = 0; blockIndex * readBlockBytes < remote.bytes; blockIndex += 1) { const offsetBytes = blockIndex * readBlockBytes; const expectedChunkBytes = Math.min(readBlockBytes, remote.bytes - offsetBytes); const chunk = await readRemoteBase64BlockWithRetry(invocation, executor, builders, remotePath, readBlockBytes, blockIndex, expectedChunkBytes, remote.bytes, actualBytes); chunks.push(chunk); actualBytes += chunk.length; chunkCount += 1; emitDownloadProgress(invocation, remotePath, blockIndex, chunkCount, actualBytes, remote.bytes, chunk.length); } const content = Buffer.concat(chunks); const actual = { bytes: content.length, sha256: sha256HexBuffer(content) }; assertTransferStat("download remote read verification", remotePath, remote, actual); return { remote, content, chunks: chunkCount }; } async function readRemoteBase64BlockWithRetry( invocation: ParsedSshInvocation, executor: SshRemoteCommandExecutor, builders: SshFileTransferCommandBuilders, remotePath: string, readBlockBytes: number, blockIndex: number, expectedChunkBytes: number, expectedBytes: number, actualBytes: number, ): Promise { const attemptErrors: Array> = []; for (let attempt = 1; attempt <= fileTransferReadBlockMaxAttempts; attempt += 1) { try { const read = await checkedFileTransfer(invocation, executor, builders, "read-b64-block", [remotePath, String(blockIndex), String(readBlockBytes)]); const encoded = read.stdout.replace(/\s+/gu, ""); const chunk = decodeRemoteBase64Block(encoded); if (chunk !== null && chunk.length === expectedChunkBytes) return chunk; const reason = chunk === null ? "invalid-base64" : chunk.length === 0 ? "empty-read" : "short-read"; attemptErrors.push({ attempt, reason, exitCode: read.exitCode, stdoutBytes: read.stdout.length, encodedBytes: encoded.length, decodedBytes: chunk?.length ?? null, expectedChunkBytes, stderrTail: read.stderr.slice(-500), }); if (attempt < fileTransferReadBlockMaxAttempts) { emitReadRetryProgress(invocation, remotePath, blockIndex, attempt, expectedBytes, actualBytes, expectedChunkBytes, chunk?.length ?? null, reason); await delayMs(fileTransferReadEmptyRetryDelayMs); } } catch (error) { attemptErrors.push({ attempt, error: error instanceof Error ? error.message : String(error), expectedChunkBytes, }); if (attempt < fileTransferReadBlockMaxAttempts) { emitReadRetryProgress(invocation, remotePath, blockIndex, attempt, expectedBytes, actualBytes, expectedChunkBytes, null, "remote-error"); await delayMs(fileTransferReadEmptyRetryDelayMs); } } } throw new SshFileTransferError("remote download returned an invalid block before EOF after retries", { route: invocation.route.raw, remotePath, blockIndex, attempts: fileTransferReadBlockMaxAttempts, expectedBytes, actualBytes, expectedChunkBytes, attemptErrors, }); } function decodeRemoteBase64Block(encoded: string): Buffer | null { if (encoded.length === 0) return Buffer.alloc(0); if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded) || encoded.length % 4 !== 0) return null; return Buffer.from(encoded, "base64"); } function emitDownloadProgress( invocation: ParsedSshInvocation, remotePath: string, blockIndex: number, chunkCount: number, actualBytes: number, expectedBytes: number, lastChunkBytes: number, ): void { if (chunkCount !== 1 && actualBytes < expectedBytes && chunkCount % fileTransferProgressEveryChunks !== 0) return; process.stderr.write(`${JSON.stringify({ event: "unidesk.ssh.download.progress", route: invocation.route.raw, providerId: invocation.providerId, remotePath, blockIndex, chunks: chunkCount, actualBytes, expectedBytes, lastChunkBytes, })}\n`); } function emitReadRetryProgress( invocation: ParsedSshInvocation, remotePath: string, blockIndex: number, attempt: number, expectedBytes: number, actualBytes: number, expectedChunkBytes: number, actualChunkBytes: number | null, reason: string, ): void { const event = reason === "empty-read" ? "unidesk.ssh.download.empty-read-retry" : "unidesk.ssh.download.short-read-retry"; process.stderr.write(`${JSON.stringify({ event, route: invocation.route.raw, providerId: invocation.providerId, remotePath, blockIndex, attempt, maxAttempts: fileTransferReadBlockMaxAttempts, reason, actualBytes, expectedBytes, actualChunkBytes, expectedChunkBytes, })}\n`); } function delayMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function statRemoteFile( invocation: ParsedSshInvocation, executor: SshRemoteCommandExecutor, builders: SshFileTransferCommandBuilders, remotePath: string, ): Promise { const stat = await checkedFileTransfer(invocation, executor, builders, "stat", [remotePath]); return parseFileTransferStat(stat.stdout, stat.stderr, remotePath); } async function checkedFileTransfer( invocation: ParsedSshInvocation, executor: SshRemoteCommandExecutor, builders: SshFileTransferCommandBuilders, operation: SshFileTransferOperation, args: string[], input?: string, ): Promise { 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: " ", 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 { 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 { 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\"", " ;;", " read-b64-block)", " target=$1; block_index=$2; block_size=$3", " case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac", " dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null | base64 | tr -d '\\n'", " ;;", " 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 }", " 'read-b64-block' { $blockIndex = [Int64]$arg1; $blockSize = [Int32]$arg2; if ($blockIndex -lt 0 -or $blockSize -le 0) { Fail 'invalid read block args' 2 }; $fs = [System.IO.File]::Open($target, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite); try { [void]$fs.Seek($blockIndex * $blockSize, [System.IO.SeekOrigin]::Begin); $buffer = New-Object byte[] $blockSize; $read = $fs.Read($buffer, 0, $blockSize); if ($read -gt 0) { [Console]::Out.Write([Convert]::ToBase64String($buffer, 0, $read)) } } finally { $fs.Dispose() }; 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, "''")}'`; }