From 20f8aebb8b7b3b2e2731df36afb97c7b4a297fef Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 15:12:03 -0400 Subject: [PATCH] feat(ssh): add bounded Windows relay file destination Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 2 + .../ssh-relay-runtime-workflow.test.mjs | 1 + ...elease-implementation-checklist-summary.md | 17 +- ...github-release-implementation-checklist.md | 105 +++++- ...-relay-runtime-command-file-destination.ts | 288 +++++++++++++++++ ...sh-relay-runtime-posix-file-destination.ts | 302 ++---------------- ...h-relay-runtime-system-ssh-file-channel.ts | 6 +- ...y-runtime-windows-file-destination.test.ts | 260 +++++++++++++++ ...-relay-runtime-windows-file-destination.ts | 157 +++++++++ 9 files changed, 849 insertions(+), 289 deletions(-) create mode 100644 src/main/ssh/ssh-relay-runtime-command-file-destination.ts create mode 100644 src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts create mode 100644 src/main/ssh/ssh-relay-runtime-windows-file-destination.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index fe859cd0b6d..060ed032bc6 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -288,6 +288,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-stream.test.ts \ src/main/ssh/ssh-relay-runtime-posix-control-command.test.ts \ src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts \ + src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts \ src/main/ssh/ssh-relay-runtime-posix-tree-transfer.test.ts \ src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts \ src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts \ @@ -1027,6 +1028,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-stream.test.ts ` src/main/ssh/ssh-relay-runtime-posix-control-command.test.ts ` src/main/ssh/ssh-relay-runtime-posix-file-destination.test.ts ` + src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts ` src/main/ssh/ssh-relay-runtime-posix-tree-transfer.test.ts ` src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.test.ts ` src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 4f7d8a2ec2c..a6fe6d77643 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -202,6 +202,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-runtime-source-stream.test.ts', 'ssh-relay-runtime-posix-control-command.test.ts', 'ssh-relay-runtime-posix-file-destination.test.ts', + 'ssh-relay-runtime-windows-file-destination.test.ts', 'ssh-relay-runtime-posix-tree-transfer.test.ts', 'ssh-relay-runtime-system-ssh-file-channel.test.ts', 'ssh-relay-runtime-sftp-file-destination.test.ts', diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md index 618c748228a..fa3615303f9 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md @@ -9,9 +9,20 @@ keeps commands, hashes, runner identities, timings, and failure details. A checked box means the work has evidence in the detailed ledger. Design approval alone does not complete a box. -Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — restricted-primitive -live/full-size POSIX system-SSH proof is exact-head green; record evidence and audit the next -disconnected transfer package, 2026-07-15, Codex implementation owner.** +Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected Windows +system-SSH bounded binary per-file destination is locally green; native Windows CI is next, +2026-07-15, Codex implementation owner.** +The active slice is limited to a fixed PowerShell 5.1/.NET receiver, a bounded binary stdin header, +one exact raw payload, exclusive `FileMode.CreateNew`, 64 KiB reads/writes, early-EOF and extra-byte +rejection, and owned-file cleanup. It must reuse the proven command-channel lifecycle without +changing POSIX behavior. Remote-tree composition, product callers, legacy upload/fallback/default +behavior, tuple enablement, publication, and SignPath remain out of scope. +`E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-RED-001` proves the workflow oracle passes 9/9 and the new +purpose suite fails solely because the audited production destination is absent. +`E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-001` records 14/15 Windows purpose cases locally with the real +PowerShell case honestly skipped, all 25 prior POSIX destination cases, 234 focused cases, 670 broad +relay cases, 282/282 release contracts, typecheck, full lint, formatting, diff, protected-resolver, +and no-product-consumer gates. Native Windows x64/arm64 PowerShell 5.1 proof remains required. `E-M6-POSIX-SYSTEM-SSH-TREE-AUDIT-001` requires a bounded no-input control-command owner rather than the legacy unbounded-output command helper, exclusive `0700` root creation, parent-first declared directories, exact per-file source-stream composition, owned-root cleanup, and a worst-case diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md index ef759ff3693..21a7db30459 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md @@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-15
-Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected POSIX system-SSH/no-tar transfer composition**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
-Session checkpoint: **POSIX SYSTEM-SSH LIVE FULL-SIZE EXACT-HEAD GREEN / EVIDENCE COMMIT NEXT — 2026-07-15, Codex implementation owner** — `E-M6-POSIX-SYSTEM-SSH-TREE-LIVE-CI-001` proves restricted-primitive exact full-size transfer, tree integrity, exclusive collision behavior, cancellation cleanup, connection reuse, latency, RSS, and one/four-channel bounds on native Linux x64 and arm64. All six primary native jobs, both Linux supplements, Windows x64 floor, PR Checks, Golden E2E, and computer-use pass; the artifact run is red only for the declared Windows arm64 hosted build 26200 versus required 26100 mismatch after complete runtime verification. Commit and push this evidence only, then audit the next disconnected transfer package. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
+Current phase: Milestone 6 / Work Package 5 bounded runtime transfer — **In progress — 2026-07-15, Codex implementation owner: disconnected Windows system-SSH bounded binary per-file destination**. Keep every capability disconnected from every production/default SSH path. Real Apple/SignPath rehearsals remain an explicit late gate and are not on this package's critical path.
+Session checkpoint: **WINDOWS SYSTEM-SSH PER-FILE DESTINATION LOCALLY GREEN / NATIVE WINDOWS CI NEXT — 2026-07-15, Codex implementation owner** — `E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-001` proves the disconnected fixed-command binary receiver contract, bounded header ownership, generic lifecycle extraction, every prior POSIX regression, broad relay/release/static gates, and production isolation locally. The real PowerShell 5.1 case is explicitly skipped on macOS and earns no native claim; commit and push the exact package, then require native Windows x64/arm64 proof before closing it. Do not compose a remote tree, connect a product caller, change legacy upload/fallback/default behavior, enable a tuple, publish an artifact, or perform SignPath work in this package. Merging to `main` remains prohibited, SignPath is deferred, and legacy remains the production default.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693) @@ -18135,6 +18135,98 @@ src/main/ssh/ssh-remote-node-resolution.ts src/main/ssh/ssh-remote-node-resoluti publish/launch, product/Beta/fallback/default behavior, tuple enablement, publication, Apple signing, or SignPath. Legacy remains the only production/default path. +### E-M6-WINDOWS-SYSTEM-SSH-FILE-AUDIT-001 — bounded per-file receiver contract + +- Date/owner/base: 2026-07-15, Codex implementation owner; audit and purpose-test package atop exact + base `aba3f7c649b8412b40871d8c3a82938e0094739e`. +- Scope: one disconnected per-file destination over the existing authenticated command-channel + abstraction. The client sends one fixed PowerShell 5.1/.NET receiver command, then a bounded binary + header containing protocol magic/version, strict UTF-8 path length, safe-integer payload length, + and path bytes, followed by exact raw payload bytes. No path or payload is embedded in PowerShell + source, a command argument, JSON, base64, or a whole-file buffer. +- Remote contract: use `[Console]::OpenStandardInput()`, strict UTF-8 decoding, a 64 KiB payload + buffer, and `FileStream` with `FileMode.CreateNew`, `FileAccess.Write`, `FileShare.None`, and + sequential-scan intent. Reject malformed headers, path bounds, early EOF, and one extra byte. On + any post-create failure, close the stream before deleting only the file this receiver created. +- Lifecycle contract: extract the already-proven command-file write/EOF/cancellation/forced- + settlement owner from the POSIX wrapper without changing behavior. Header acceptance is awaited + before returning the Windows destination; a header/open failure is joined with bounded channel + cleanup. The exact caller signal remains single-owned. +- Required RED/green proof: purpose cases for fixed command/path separation, exact header bytes, + invalid input before channel open, retained-header cancellation, remote nonzero settlement, + zero-byte and chunked binary payloads, collision preservation, early EOF/extra-byte cleanup, and a + native Windows PowerShell 5.1 process oracle. Pin the suite exactly once in both native artifact + workflow command families and update the workflow oracle before implementation. +- Explicit residual boundary: no Windows remote-tree/staging-root owner, case-collision scan, remote + capability classification/cache, live OpenSSH remote, full-size/runtime measurement, endpoint- + protection fault, long-path qualification, bundled-Node verification/install/publish/launch, + product/Beta/settings/fallback/tuple/publication/default behavior, or SignPath is included. Legacy + remains the only production/default path and every tuple stays disabled. + +### E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-RED-001 — audited destination is absent + +- Date/owner/base: 2026-07-15, Codex implementation owner; purpose test, native-workflow wiring, and + checklist-only diff atop exact base `aba3f7c649b8412b40871d8c3a82938e0094739e` on Apple arm64, + macOS 26.2 build 25C56, Node 26.0.0, pnpm 10.24.0. +- Command: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 +src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts +config/scripts/ssh-relay-runtime-workflow.test.mjs`. +- Result: the 9/9 native-workflow oracle cases pass and prove the purpose suite occurs exactly once + in both native artifact command families. The purpose suite fails before collection solely because + `./ssh-relay-runtime-windows-file-destination` does not exist. This is the intended RED boundary; + no production module has been added or changed. +- Residual boundary: no PowerShell behavior or Windows runner claim follows from missing-module RED. + All audit, purpose, native Windows, POSIX regression, broad relay, static, and isolation gates remain + required. + +### E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-001 — bounded receiver package is locally green + +- Date/owner/base/runner: 2026-07-15, Codex implementation owner; uncommitted implementation, + purpose-test, workflow, and checklist package atop exact base + `aba3f7c649b8412b40871d8c3a82938e0094739e` on Apple arm64, macOS 26.2 build 25C56, Node 26.0.0, + pnpm 10.24.0. The local Node version is newer than the repository's Node 24 contract, and the real + PowerShell 5.1 purpose case is skipped off Windows, so exact-head native workflow proof remains + mandatory. +- Implementation: extract the proven 250 ms graceful/2-second forced command-file lifecycle into + `ssh-relay-runtime-command-file-destination.ts` while retaining POSIX error identity and all 25 + POSIX purpose cases. Add a disconnected Windows wrapper with an at-most 32,788-byte header, + 32-KiB strict UTF-8 path bound, safe-integer payload size, fixed encoded receiver command, + 64-KiB remote buffer, `CreateNew`/`FileShare.None`/sequential-scan stream, exact EOF/extra-byte + rejection, and owned-file cleanup. The existing legacy Windows uploader is unchanged. +- Focused command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1` with the Windows purpose, POSIX control/file/tree, system-channel, source-tree/scan/ + stream, system-fallback, connection, and workflow-oracle suites. Result: 11 files, 234 passed and two + declared platform skips out of 236; 7.95 seconds real, 171,311,104-byte maximum RSS, 96,097,560-byte + peak footprint, zero swaps and block I/O. The Windows file suite contributes 14 passed plus one + macOS-skipped real-PowerShell case; the workflow oracle passes 9/9. +- Broad relay command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-*.test.ts`. Result: 51 files passed, five files skipped; 670 + passed and eight declared skips out of 678; 23.96 seconds real, 248,102,912-byte maximum RSS, + 96,163,264-byte peak footprint, zero swaps and block I/O. +- Release-contract command: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-*.test.mjs`. Result: 50 files + and 282/282 cases pass; 14.35 seconds real, 189,628,416-byte maximum RSS, 95,769,976-byte peak + footprint, zero swaps and block I/O. +- Static evidence: `/usr/bin/time -l pnpm typecheck` passes in 2.45 seconds real with + 1,241,055,232-byte maximum RSS and 96,146,784-byte peak footprint. `/usr/bin/time -l pnpm lint` + passes in 20.18 seconds real with 2,063,761,408-byte maximum RSS and 96,343,488-byte peak footprint; + its 26 warnings are pre-existing outside this diff. Full lint proves switch exhaustiveness, 41 + reliability gates, the 355-entry max-lines ratchet with no new bypass, bundled guides, and all + localization checks. Targeted `oxlint` and `oxfmt --check` pass. +- Isolation evidence: `git diff --check` passes; `git diff --exit-code HEAD -- +src/main/ssh/ssh-remote-node-resolution.ts src/main/ssh/ssh-remote-node-resolution.test.ts` is empty. + An asserted non-test reference search finds the Windows destination export only in its owning + production module. The intended diff is limited to the generic lifecycle extraction, POSIX type/ + wrapper delegation, system-channel type generalization, disconnected Windows module/purpose test, + native workflow/oracle wiring, and both checklist documents. No legacy uploader, product importer, + settings, Beta, fallback, tuple, publication, default-path, or signing behavior changes. +- Residual boundary: local execution does not prove that the fixed script parses or behaves under + Windows PowerShell 5.1/.NET Framework 4.8. Exact-head native Windows x64/arm64 purpose proof and all + adjacent native regression cells remain required. A Windows remote tree/staging owner, live + OpenSSH, full-size runtime, endpoint-protection faults, long-path qualification, capability + classification/cache, remote verification/install/publish/launch, product/Beta/fallback/default + wiring, tuple enablement, publication, Apple signing, and SignPath remain absent. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -18193,10 +18285,11 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Audit and RED-test only the smallest purpose-named live/full-size loopback OpenSSH proof that calls -the exact disconnected POSIX system-SSH tree owner on target-native Linux x64/arm64 CI. Do not -check the broad Milestone 6 SFTP or POSIX system-SSH items yet. Do not add an Electron/startup/product -importer, per-target mode wiring, fallback, tuple enablement, release publication, or default behavior. +Commit and push the exact `E-M6-WINDOWS-SYSTEM-SSH-FILE-LOCAL-001` package, then require the real +PowerShell 5.1 process oracle on native Windows x64/arm64 plus all-six native and adjacent repository +regression cells. Do not check the broad Milestone 6 Windows system-SSH item yet. Do not add a remote- +tree owner, Electron/startup/product importer, per-target mode wiring, fallback, tuple enablement, +release publication, or default behavior. Keep Node upstream `.tar.xz` inputs, Windows ZIP, `ORCA_RELAY_PATH`, existing desktop required-assets behavior, detached-signature byte encoding, Windows arm64 build 26100, macOS 13.5, Linux kernel 4.18, release-cut, desktop builds, publication, and every tuple separately gated. diff --git a/src/main/ssh/ssh-relay-runtime-command-file-destination.ts b/src/main/ssh/ssh-relay-runtime-command-file-destination.ts new file mode 100644 index 00000000000..990b88803b4 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-command-file-destination.ts @@ -0,0 +1,288 @@ +import type { SshRelayRuntimeSourceDestination } from './ssh-relay-runtime-source-stream' + +type ChannelWriteCallback = (error?: Error) => void + +export type SshRelayRuntimeCommandFileChannel = Readonly<{ + write: (chunk: Buffer, callback: ChannelWriteCallback) => void + end: () => void + settled: Promise + requestClose: () => void + forceClose: () => void +}> + +export type OpenSshRelayRuntimeCommandFileDestinationOptions = Readonly<{ + command: string + fileKind: 'POSIX' | 'Windows' + signal: AbortSignal + openChannel: (command: string, signal: AbortSignal) => Promise +}> + +export const SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS = Object.freeze({ + gracefulCloseMs: 250, + totalCloseMs: 2_000 +}) + +type DestinationState = 'open' | 'closing' | 'complete' | 'aborting' | 'aborted' +type ChannelSettlement = Readonly<{ ok: true } | { ok: false; error: unknown }> +type AbortOutcome = Readonly<{ reason: unknown }> + +function validateOptions(options: OpenSshRelayRuntimeCommandFileDestinationOptions): void { + if ( + !options || + typeof options.command !== 'string' || + options.command === '' || + (options.fileKind !== 'POSIX' && options.fileKind !== 'Windows') || + typeof options.openChannel !== 'function' || + !options.signal + ) { + throw new Error('SSH relay runtime command file destination input is invalid') + } +} + +function validateChannel( + channel: SshRelayRuntimeCommandFileChannel, + fileKind: 'POSIX' | 'Windows' +): void { + if ( + !channel || + typeof channel.write !== 'function' || + typeof channel.end !== 'function' || + typeof channel.settled?.then !== 'function' || + typeof channel.requestClose !== 'function' || + typeof channel.forceClose !== 'function' + ) { + throw new Error(`SSH relay runtime ${fileKind} file channel is invalid`) + } +} + +function cleanupFailure( + failures: readonly unknown[], + fileKind: 'POSIX' | 'Windows' +): unknown | undefined { + if (failures.length === 0) { + return undefined + } + return failures.length === 1 + ? failures[0] + : new AggregateError(failures, `SSH relay runtime ${fileKind} file channel cleanup failed`) +} + +function waitForSettlement( + settlement: Promise, + timeoutMs: number +): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs) + void settlement.then(() => { + clearTimeout(timeout) + resolve(true) + }) + }) +} + +async function settleCancelledChannel( + channel: SshRelayRuntimeCommandFileChannel, + settlement: Promise, + fileKind: 'POSIX' | 'Windows' +): Promise { + const failures: unknown[] = [] + try { + channel.requestClose() + } catch (error) { + failures.push(error) + } + + const graceful = await waitForSettlement( + settlement, + SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS.gracefulCloseMs + ) + if (!graceful) { + try { + channel.forceClose() + } catch (error) { + failures.push(error) + } + const forced = await waitForSettlement( + settlement, + SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS.totalCloseMs - + SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS.gracefulCloseMs + ) + if (!forced) { + failures.unshift(new Error(`SSH relay runtime ${fileKind} file channel settlement timed out`)) + } + } + + const failure = cleanupFailure(failures, fileKind) + if (failure !== undefined) { + throw failure + } +} + +function createDestination( + signal: AbortSignal, + channel: SshRelayRuntimeCommandFileChannel, + fileKind: 'POSIX' | 'Windows' +): SshRelayRuntimeSourceDestination { + const channelSettlement: Promise = channel.settled.then( + () => ({ ok: true }), + (error: unknown) => ({ ok: false, error }) + ) + let state: DestinationState = 'open' + let activeOperation: Promise | undefined + let abortPromise: Promise | undefined + let abortReason: unknown + let resolveAbortOutcome: (outcome: AbortOutcome) => void = () => {} + const abortOutcome = new Promise((resolve) => { + resolveAbortOutcome = resolve + }) + + const assertWritable = (): void => { + if (state !== 'open') { + throw new Error(`SSH relay runtime ${fileKind} file destination is closed`) + } + if (activeOperation) { + throw new Error(`SSH relay runtime ${fileKind} file destination has a concurrent operation`) + } + } + const runExclusive = (operation: () => Promise): Promise => { + const pending = operation() + activeOperation = pending + void pending + .finally(() => { + if (activeOperation === pending) { + activeOperation = undefined + } + }) + .catch(() => {}) + return pending + } + const waitForOperation = async (operation: Promise): Promise => { + const result = await Promise.race([ + operation.then( + () => ({ kind: 'complete' as const }), + (error: unknown) => ({ kind: 'failed' as const, error }) + ), + abortOutcome.then((outcome) => ({ kind: 'aborted' as const, outcome })) + ]) + if (result.kind === 'aborted') { + throw result.outcome.reason + } + if (state === 'aborting' || state === 'aborted') { + throw (await abortOutcome).reason + } + if (result.kind === 'failed') { + throw result.error + } + } + + const performAbort = async (): Promise => { + const reason = abortReason + let failure: unknown + try { + await settleCancelledChannel(channel, channelSettlement, fileKind) + } catch (error) { + failure = error + } finally { + state = 'aborted' + signal.removeEventListener('abort', onSignalAbort) + // Why: retained transport buffers are released only after the channel settles or times out. + resolveAbortOutcome({ reason }) + } + if (failure !== undefined) { + throw failure + } + } + const abort = (reason: unknown): Promise => { + if (abortPromise) { + return abortPromise + } + if (state === 'complete' || state === 'aborted') { + abortPromise = Promise.resolve() + return abortPromise + } + state = 'aborting' + abortReason = reason + abortPromise = performAbort() + return abortPromise + } + function onSignalAbort(): void { + void abort(signal.reason).catch(() => {}) + } + + const write = (chunk: Buffer): Promise => { + try { + assertWritable() + } catch (error) { + return Promise.reject(error) + } + if (!Buffer.isBuffer(chunk) || chunk.length === 0) { + return Promise.reject( + new Error(`SSH relay runtime ${fileKind} file destination chunk is empty`) + ) + } + return runExclusive(async () => { + signal.throwIfAborted() + // Why: the callback is the byte-retention boundary before a source worker reuses its buffer. + const accepted = new Promise((resolve, reject) => { + channel.write(chunk, (error) => (error ? reject(error) : resolve())) + }) + await waitForOperation(accepted) + signal.throwIfAborted() + }) + } + const close = (): Promise => { + try { + assertWritable() + } catch (error) { + return Promise.reject(error) + } + state = 'closing' + return runExclusive(async () => { + signal.throwIfAborted() + channel.end() + const settled = await Promise.race([ + channelSettlement, + abortOutcome.then((outcome) => ({ ok: false as const, error: outcome.reason })) + ]) + if (state === 'aborting' || state === 'aborted') { + throw (await abortOutcome).reason + } + if (!settled.ok) { + throw settled.error + } + signal.throwIfAborted() + state = 'complete' + signal.removeEventListener('abort', onSignalAbort) + }) + } + + signal.addEventListener('abort', onSignalAbort, { once: true }) + if (signal.aborted) { + onSignalAbort() + } + return Object.freeze({ write, close, abort }) +} + +export async function openSshRelayRuntimeCommandFileDestination( + options: OpenSshRelayRuntimeCommandFileDestinationOptions +): Promise { + validateOptions(options) + options.signal.throwIfAborted() + const channel = await options.openChannel(options.command, options.signal) + validateChannel(channel, options.fileKind) + const destination = createDestination(options.signal, channel, options.fileKind) + try { + options.signal.throwIfAborted() + return destination + } catch (error) { + try { + await destination.abort(error) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `SSH relay runtime ${options.fileKind} file open cancellation cleanup failed` + ) + } + throw error + } +} diff --git a/src/main/ssh/ssh-relay-runtime-posix-file-destination.ts b/src/main/ssh/ssh-relay-runtime-posix-file-destination.ts index 1bf7858351b..56b0f9a3dd9 100644 --- a/src/main/ssh/ssh-relay-runtime-posix-file-destination.ts +++ b/src/main/ssh/ssh-relay-runtime-posix-file-destination.ts @@ -1,14 +1,11 @@ +import { + openSshRelayRuntimeCommandFileDestination, + SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS, + type SshRelayRuntimeCommandFileChannel +} from './ssh-relay-runtime-command-file-destination' import type { SshRelayRuntimeSourceDestination } from './ssh-relay-runtime-source-stream' -type ChannelWriteCallback = (error?: Error) => void - -export type SshRelayRuntimePosixFileChannel = Readonly<{ - write: (chunk: Buffer, callback: ChannelWriteCallback) => void - end: () => void - settled: Promise - requestClose: () => void - forceClose: () => void -}> +export type SshRelayRuntimePosixFileChannel = SshRelayRuntimeCommandFileChannel export type OpenSshRelayRuntimePosixFileDestinationOptions = Readonly<{ remotePath: string @@ -17,30 +14,8 @@ export type OpenSshRelayRuntimePosixFileDestinationOptions = Readonly<{ openChannel: (command: string, signal: AbortSignal) => Promise }> -export const SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS = Object.freeze({ - gracefulCloseMs: 250, - totalCloseMs: 2_000 -}) - -type DestinationState = 'open' | 'closing' | 'complete' | 'aborting' | 'aborted' - -type ChannelSettlement = Readonly<{ ok: true } | { ok: false; error: unknown }> - -type AbortOutcome = Readonly<{ reason: unknown }> - -function validateRemotePath(remotePath: string): void { - const segments = remotePath.split('/') - if ( - remotePath === '/' || - !remotePath.startsWith('/') || - remotePath.includes('\0') || - remotePath.includes('\n') || - remotePath.includes('\r') || - segments.slice(1).some((segment) => segment === '' || segment === '.' || segment === '..') - ) { - throw new Error('SSH relay runtime POSIX file destination path is invalid') - } -} +export const SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS = + SSH_RELAY_RUNTIME_COMMAND_FILE_DESTINATION_LIMITS function validateOptions(options: OpenSshRelayRuntimePosixFileDestinationOptions): void { if ( @@ -51,25 +26,22 @@ function validateOptions(options: OpenSshRelayRuntimePosixFileDestinationOptions ) { throw new Error('SSH relay runtime POSIX file destination input is invalid') } - validateRemotePath(options.remotePath) + const segments = options.remotePath.split('/') + if ( + options.remotePath === '/' || + !options.remotePath.startsWith('/') || + options.remotePath.includes('\0') || + options.remotePath.includes('\n') || + options.remotePath.includes('\r') || + segments.slice(1).some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error('SSH relay runtime POSIX file destination path is invalid') + } if (options.mode !== 0o644 && options.mode !== 0o755) { throw new Error('SSH relay runtime POSIX file destination mode is invalid') } } -function validateChannel(channel: SshRelayRuntimePosixFileChannel): void { - if ( - !channel || - typeof channel.write !== 'function' || - typeof channel.end !== 'function' || - typeof channel.settled?.then !== 'function' || - typeof channel.requestClose !== 'function' || - typeof channel.forceClose !== 'function' - ) { - throw new Error('SSH relay runtime POSIX file channel is invalid') - } -} - function quotePosixShellArgument(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } @@ -81,238 +53,14 @@ function buildCommand(remotePath: string, mode: 0o644 | 0o755): string { return `umask 077; set -C; cat > ${quotedPath} && chmod ${finalMode} ${quotedPath}` } -function cleanupFailure(failures: readonly unknown[]): unknown | undefined { - if (failures.length === 0) { - return undefined - } - return failures.length === 1 - ? failures[0] - : new AggregateError(failures, 'SSH relay runtime POSIX file channel cleanup failed') -} - -function waitForSettlement( - settlement: Promise, - timeoutMs: number -): Promise { - return new Promise((resolve) => { - const timeout = setTimeout(() => resolve(false), timeoutMs) - void settlement.then(() => { - clearTimeout(timeout) - resolve(true) - }) - }) -} - -async function settleCancelledChannel( - channel: SshRelayRuntimePosixFileChannel, - settlement: Promise -): Promise { - const failures: unknown[] = [] - try { - channel.requestClose() - } catch (error) { - failures.push(error) - } - - const graceful = await waitForSettlement( - settlement, - SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS.gracefulCloseMs - ) - if (!graceful) { - try { - channel.forceClose() - } catch (error) { - failures.push(error) - } - const forced = await waitForSettlement( - settlement, - SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS.totalCloseMs - - SSH_RELAY_RUNTIME_POSIX_FILE_DESTINATION_LIMITS.gracefulCloseMs - ) - if (!forced) { - failures.unshift(new Error('SSH relay runtime POSIX file channel settlement timed out')) - } - } - - const failure = cleanupFailure(failures) - if (failure !== undefined) { - throw failure - } -} - -function createDestination( - options: OpenSshRelayRuntimePosixFileDestinationOptions, - channel: SshRelayRuntimePosixFileChannel -): SshRelayRuntimeSourceDestination { - const { signal } = options - const channelSettlement: Promise = channel.settled.then( - () => ({ ok: true }), - (error: unknown) => ({ ok: false, error }) - ) - let state: DestinationState = 'open' - let activeOperation: Promise | undefined - let abortPromise: Promise | undefined - let abortReason: unknown - let resolveAbortOutcome: (outcome: AbortOutcome) => void = () => {} - const abortOutcome = new Promise((resolve) => { - resolveAbortOutcome = resolve - }) - - const assertWritable = (): void => { - if (state !== 'open') { - throw new Error('SSH relay runtime POSIX file destination is closed') - } - if (activeOperation) { - throw new Error('SSH relay runtime POSIX file destination has a concurrent operation') - } - } - - const runExclusive = (operation: () => Promise): Promise => { - const pending = operation() - activeOperation = pending - void pending - .finally(() => { - if (activeOperation === pending) { - activeOperation = undefined - } - }) - .catch(() => {}) - return pending - } - - const waitForOperation = async (operation: Promise): Promise => { - const result = await Promise.race([ - operation.then( - () => ({ kind: 'complete' as const }), - (error: unknown) => ({ kind: 'failed' as const, error }) - ), - abortOutcome.then((outcome) => ({ kind: 'aborted' as const, outcome })) - ]) - if (result.kind === 'aborted') { - throw result.outcome.reason - } - if (state === 'aborting' || state === 'aborted') { - const outcome = await abortOutcome - throw outcome.reason - } - if (result.kind === 'failed') { - throw result.error - } - } - - const performAbort = async (): Promise => { - const reason = abortReason - let failure: unknown - try { - await settleCancelledChannel(channel, channelSettlement) - } catch (error) { - failure = error - } finally { - state = 'aborted' - signal.removeEventListener('abort', onSignalAbort) - // Why: retained transport buffers are released only after the channel has settled or timed out. - resolveAbortOutcome({ reason }) - } - if (failure !== undefined) { - throw failure - } - } - - const abort = (reason: unknown): Promise => { - if (abortPromise) { - return abortPromise - } - if (state === 'complete' || state === 'aborted') { - abortPromise = Promise.resolve() - return abortPromise - } - state = 'aborting' - abortReason = reason - abortPromise = performAbort() - return abortPromise - } - - function onSignalAbort(): void { - void abort(signal.reason).catch(() => {}) - } - - const write = (chunk: Buffer): Promise => { - try { - assertWritable() - } catch (error) { - return Promise.reject(error) - } - if (!Buffer.isBuffer(chunk) || chunk.length === 0) { - return Promise.reject(new Error('SSH relay runtime POSIX file destination chunk is empty')) - } - return runExclusive(async () => { - signal.throwIfAborted() - // Why: the callback is the byte-retention boundary before a source worker reuses its buffer. - const accepted = new Promise((resolve, reject) => { - channel.write(chunk, (error) => (error ? reject(error) : resolve())) - }) - await waitForOperation(accepted) - signal.throwIfAborted() - }) - } - - const close = (): Promise => { - try { - assertWritable() - } catch (error) { - return Promise.reject(error) - } - state = 'closing' - return runExclusive(async () => { - signal.throwIfAborted() - channel.end() - const settled = await Promise.race([ - channelSettlement, - abortOutcome.then((outcome) => ({ ok: false as const, error: outcome.reason })) - ]) - if (state === 'aborting' || state === 'aborted') { - const outcome = await abortOutcome - throw outcome.reason - } - if (!settled.ok) { - throw settled.error - } - signal.throwIfAborted() - state = 'complete' - signal.removeEventListener('abort', onSignalAbort) - }) - } - - signal.addEventListener('abort', onSignalAbort, { once: true }) - if (signal.aborted) { - onSignalAbort() - } - return Object.freeze({ write, close, abort }) -} - export async function openSshRelayRuntimePosixFileDestination( options: OpenSshRelayRuntimePosixFileDestinationOptions ): Promise { validateOptions(options) - options.signal.throwIfAborted() - const channel = await options.openChannel( - buildCommand(options.remotePath, options.mode), - options.signal - ) - validateChannel(channel) - const destination = createDestination(options, channel) - try { - options.signal.throwIfAborted() - return destination - } catch (error) { - try { - await destination.abort(error) - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - 'SSH relay runtime POSIX file open cancellation cleanup failed' - ) - } - throw error - } + return await openSshRelayRuntimeCommandFileDestination({ + command: buildCommand(options.remotePath, options.mode), + fileKind: 'POSIX', + signal: options.signal, + openChannel: options.openChannel + }) } diff --git a/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts index ace3381340d..daba2e3189c 100644 --- a/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts +++ b/src/main/ssh/ssh-relay-runtime-system-ssh-file-channel.ts @@ -1,5 +1,5 @@ import type { ClientChannel } from 'ssh2' -import type { SshRelayRuntimePosixFileChannel } from './ssh-relay-runtime-posix-file-destination' +import type { SshRelayRuntimeCommandFileChannel } from './ssh-relay-runtime-command-file-destination' import type { SystemSshCommandChannel } from './system-ssh-command' export type SshRelayRuntimeSystemSshConnection = Readonly<{ @@ -134,7 +134,7 @@ function waitForSettlement(channel: SystemSshCommandChannel): Promise { }) } -function adaptChannel(channel: SystemSshCommandChannel): SshRelayRuntimePosixFileChannel { +function adaptChannel(channel: SystemSshCommandChannel): SshRelayRuntimeCommandFileChannel { validateChannel(channel) const settled = waitForSettlement(channel) const input = channel.stdin as unknown as { @@ -184,7 +184,7 @@ export async function openSshRelayRuntimeSystemSshFileChannel( connection: SshRelayRuntimeSystemSshConnection, command: string, signal: AbortSignal -): Promise { +): Promise { validateInput(connection, command, signal) // Why: the file destination owns this signal's bounded teardown; forwarding it would double-kill. const channel = (await connection.exec(command)) as SystemSshCommandChannel diff --git a/src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts b/src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts new file mode 100644 index 00000000000..cb7b06d2f65 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-windows-file-destination.test.ts @@ -0,0 +1,260 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + openSshRelayRuntimeWindowsFileDestination, + SSH_RELAY_RUNTIME_WINDOWS_FILE_DESTINATION_LIMITS +} from './ssh-relay-runtime-windows-file-destination' + +type Callback = (error?: Error) => void + +function createChannel() { + let resolveSettled: () => void = () => {} + let rejectSettled: (error: Error) => void = () => {} + const settled = new Promise((resolve, reject) => { + resolveSettled = resolve + rejectSettled = reject + }) + const channel = { + write: vi.fn((_chunk: Buffer, callback: Callback) => callback()), + end: vi.fn(), + settled, + requestClose: vi.fn(), + forceClose: vi.fn() + } + return { channel, resolve: resolveSettled, reject: rejectSettled } +} + +function decodeHeader(frame: Buffer) { + const pathLength = frame.readUInt32LE(8) + return { + magic: frame.subarray(0, 8).toString('ascii'), + pathLength, + expectedSize: frame.readBigUInt64LE(12), + remotePath: frame.subarray(20, 20 + pathLength).toString('utf8') + } +} + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] + if (!encoded) { + throw new Error('PowerShell receiver command is not encoded') + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +function openDestination( + channel: ReturnType['channel'], + overrides: Partial[0]> = {} +) { + return openSshRelayRuntimeWindowsFileDestination({ + remotePath: 'C:/Users/测试/.orca-remote/stage/bin/node.exe', + expectedSize: 65_537, + signal: new AbortController().signal, + openChannel: vi.fn(async () => channel), + ...overrides + }) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('SSH relay runtime Windows system-SSH file destination', () => { + it('opens one fixed receiver and awaits an exact bounded binary header', async () => { + const { channel, resolve } = createChannel() + let headerCallback: Callback | undefined + channel.write.mockImplementationOnce((_chunk, callback) => { + headerCallback = callback + }) + const openChannel = vi.fn(async (_command: string, _signal: AbortSignal) => channel) + let opened = false + const opening = openDestination(channel, { openChannel }).then((destination) => { + opened = true + return destination + }) + + await vi.waitFor(() => expect(channel.write).toHaveBeenCalledOnce()) + expect(opened).toBe(false) + expect(openChannel).toHaveBeenCalledOnce() + const command = openChannel.mock.calls[0]?.[0] ?? '' + expect(command).toMatch(/^powershell\.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/u) + expect(command).not.toContain('测试') + expect(command).not.toContain('65_537') + const script = decodePowerShellCommand(command) + expect(script).toContain('[Console]::OpenStandardInput()') + expect(script).toContain('[IO.FileMode]::CreateNew') + expect(script).toContain('[IO.FileShare]::None') + expect(script).toContain('New-Object byte[] 65536') + expect(script).toContain('$inputStream.ReadByte() -ne -1') + for (const forbidden of ['ReadToEnd', 'CopyTo(', 'FromBase64String', 'ConvertFrom-Json']) { + expect(script).not.toContain(forbidden) + } + + const frame = channel.write.mock.calls[0]?.[0] + expect(Buffer.isBuffer(frame)).toBe(true) + expect(frame.length).toBeLessThanOrEqual( + SSH_RELAY_RUNTIME_WINDOWS_FILE_DESTINATION_LIMITS.maximumHeaderBytes + ) + expect(decodeHeader(frame)).toEqual({ + magic: 'ORCARLY1', + pathLength: Buffer.byteLength('C:/Users/测试/.orca-remote/stage/bin/node.exe'), + expectedSize: 65_537n, + remotePath: 'C:/Users/测试/.orca-remote/stage/bin/node.exe' + }) + + headerCallback?.() + const destination = await opening + const closing = destination.close() + resolve() + await closing + }) + + it.each([ + ['', 1], + ['relative/file', 1], + ['C:/', 1], + ['C:/owned/../file', 1], + ['C:/owned/report.txt:stream', 1], + ['C:/owned/NUL', 1], + ['C:/owned/file\nnext', 1], + ['C:/owned/file', -1], + ['C:/owned/file', Number.MAX_SAFE_INTEGER + 1], + ['C:/owned/file', 1.5] + ])('rejects hostile path %j or size %j before channel open', async (remotePath, expectedSize) => { + const { channel } = createChannel() + const openChannel = vi.fn(async () => channel) + + await expect( + openDestination(channel, { remotePath, expectedSize, openChannel }) + ).rejects.toThrow(/invalid/i) + expect(openChannel).not.toHaveBeenCalled() + }) + + it('joins a failed header write with bounded channel cleanup', async () => { + const { channel, resolve } = createChannel() + channel.write.mockImplementationOnce((_chunk, callback) => { + callback(new Error('header rejected')) + }) + channel.requestClose.mockImplementation(() => resolve()) + + await expect(openDestination(channel)).rejects.toThrow('header rejected') + expect(channel.requestClose).toHaveBeenCalledOnce() + expect(channel.forceClose).not.toHaveBeenCalled() + }) + + it('settles retained-header cancellation before rejecting open', async () => { + const { channel, resolve } = createChannel() + const controller = new AbortController() + const reason = new Error('cancelled retained Windows header') + channel.write.mockImplementationOnce(() => {}) + channel.requestClose.mockImplementation(() => resolve()) + const opening = openDestination(channel, { signal: controller.signal }) + + await vi.waitFor(() => expect(channel.write).toHaveBeenCalledOnce()) + controller.abort(reason) + await expect(opening).rejects.toBe(reason) + expect(channel.requestClose).toHaveBeenCalledOnce() + expect(channel.forceClose).not.toHaveBeenCalled() + }) + + it('propagates receiver failure only after EOF and remote settlement', async () => { + const { channel, reject } = createChannel() + const destination = await openDestination(channel, { expectedSize: 0 }) + const closing = destination.close() + + expect(channel.end).toHaveBeenCalledOnce() + reject(new Error('receiver rejected extra byte')) + await expect(closing).rejects.toThrow('receiver rejected extra byte') + }) + + it.skipIf(process.platform !== 'win32')( + 'proves PowerShell 5.1 binary fidelity, exclusive collision, and exact-size rejection', + async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-relay-win-file-')) + try { + const payload = Buffer.from([0, 13, 10, 255, 128, 1, 2, 3, 0, 254]) + const exactPath = path.join(root, '测试 payload.bin') + const exact = await openSshRelayRuntimeWindowsFileDestination({ + remotePath: exactPath, + expectedSize: payload.length, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await exact.write(payload.subarray(0, 3)) + await exact.write(payload.subarray(3)) + await exact.close() + expect(await readFile(exactPath)).toEqual(payload) + + const collisionPath = path.join(root, 'collision.bin') + await writeFile(collisionPath, 'original') + const collision = await openSshRelayRuntimeWindowsFileDestination({ + remotePath: collisionPath, + expectedSize: 0, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await expect(collision.close()).rejects.toThrow(/PowerShell receiver failed/i) + expect(await readFile(collisionPath, 'utf8')).toBe('original') + + for (const [name, expectedSize, bytes] of [ + ['early.bin', 2, Buffer.from([1])], + ['extra.bin', 1, Buffer.from([1, 2])] + ] as const) { + const remotePath = path.join(root, name) + const destination = await openSshRelayRuntimeWindowsFileDestination({ + remotePath, + expectedSize, + signal: new AbortController().signal, + openChannel: openPowerShellChannel + }) + await destination.write(bytes) + await expect(destination.close()).rejects.toThrow(/PowerShell receiver failed/i) + await expect(readFile(remotePath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }, + 30_000 + ) +}) + +async function openPowerShellChannel(command: string) { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] + if (!encoded) { + throw new Error('PowerShell receiver command is not encoded') + } + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], + { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true } + ) + let diagnostic = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + diagnostic = `${diagnostic}${chunk}`.slice(-4_096) + }) + const settled = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`PowerShell receiver failed (${code ?? signal}): ${diagnostic}`)) + } + }) + }) + return { + write: (chunk: Buffer, callback: Callback) => { + child.stdin.write(chunk, (error) => callback(error ?? undefined)) + }, + end: () => child.stdin.end(), + settled, + requestClose: () => child.kill(), + forceClose: () => child.kill('SIGKILL') + } +} diff --git a/src/main/ssh/ssh-relay-runtime-windows-file-destination.ts b/src/main/ssh/ssh-relay-runtime-windows-file-destination.ts new file mode 100644 index 00000000000..91397e742b6 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-windows-file-destination.ts @@ -0,0 +1,157 @@ +import { assertSafeRemotePathSegment, normalizeWindowsRemotePath } from './ssh-remote-platform' +import { powerShellCommand } from './ssh-remote-powershell' +import { + openSshRelayRuntimeCommandFileDestination, + type SshRelayRuntimeCommandFileChannel +} from './ssh-relay-runtime-command-file-destination' +import type { SshRelayRuntimeSourceDestination } from './ssh-relay-runtime-source-stream' + +export type OpenSshRelayRuntimeWindowsFileDestinationOptions = Readonly<{ + remotePath: string + expectedSize: number + signal: AbortSignal + openChannel: (command: string, signal: AbortSignal) => Promise +}> + +const HEADER_MAGIC = Buffer.from('ORCARLY1', 'ascii') +const FIXED_HEADER_BYTES = 20 +const MAXIMUM_PATH_BYTES = 32 * 1024 +const PAYLOAD_BUFFER_BYTES = 64 * 1024 + +export const SSH_RELAY_RUNTIME_WINDOWS_FILE_DESTINATION_LIMITS = Object.freeze({ + maximumHeaderBytes: FIXED_HEADER_BYTES + MAXIMUM_PATH_BYTES, + maximumPathBytes: MAXIMUM_PATH_BYTES, + payloadBufferBytes: PAYLOAD_BUFFER_BYTES +}) + +const RECEIVER_SCRIPT = ` +$ErrorActionPreference = 'Stop' +function Read-Exact([System.IO.Stream]$Stream, [byte[]]$Buffer, [int]$Count) { + $offset = 0 + while ($offset -lt $Count) { + $read = $Stream.Read($Buffer, $offset, $Count - $offset) + if ($read -eq 0) { throw 'SSH relay runtime Windows receiver early EOF' } + $offset += $read + } +} +$inputStream = [Console]::OpenStandardInput() +$outputStream = $null +$ownsFile = $false +try { + [byte[]]$header = New-Object byte[] ${FIXED_HEADER_BYTES} + Read-Exact $inputStream $header ${FIXED_HEADER_BYTES} + if ([Text.Encoding]::ASCII.GetString($header, 0, 8) -ne 'ORCARLY1') { throw 'bad header' } + [uint32]$pathLength = [BitConverter]::ToUInt32($header, 8) + [int64]$expectedSize = [BitConverter]::ToInt64($header, 12) + if ($pathLength -eq 0 -or $pathLength -gt ${MAXIMUM_PATH_BYTES}) { throw 'bad path length' } + if ($expectedSize -lt 0 -or $expectedSize -gt 9007199254740991) { throw 'bad size' } + [byte[]]$pathBytes = New-Object byte[] ([int]$pathLength) + Read-Exact $inputStream $pathBytes ([int]$pathLength) + $utf8 = New-Object Text.UTF8Encoding($false, $true) + $path = $utf8.GetString($pathBytes) + if (-not [IO.Path]::IsPathRooted($path) -or $path.IndexOf([char]0) -ge 0) { throw 'bad path' } + $outputStream = New-Object System.IO.FileStream($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None, ${PAYLOAD_BUFFER_BYTES}, [IO.FileOptions]::SequentialScan) + $ownsFile = $true + [byte[]]$buffer = New-Object byte[] ${PAYLOAD_BUFFER_BYTES} + [int64]$remaining = $expectedSize + while ($remaining -gt 0) { + $wanted = [int][Math]::Min([int64]$buffer.Length, $remaining) + $read = $inputStream.Read($buffer, 0, $wanted) + if ($read -eq 0) { throw 'SSH relay runtime Windows receiver early EOF' } + $outputStream.Write($buffer, 0, $read) + $remaining -= $read + } + if ($inputStream.ReadByte() -ne -1) { throw 'SSH relay runtime Windows receiver extra byte' } + $outputStream.Flush() + $outputStream.Dispose() + $outputStream = $null + $ownsFile = $false +} catch { + try { if ($null -ne $outputStream) { $outputStream.Dispose() } } catch {} + try { if ($ownsFile) { [IO.File]::Delete($path) } } catch {} + throw 'SSH relay runtime Windows receiver failed' +} +`.trim() + +const RECEIVER_COMMAND = powerShellCommand(RECEIVER_SCRIPT) + +function normalizeRemotePath(remotePath: string): string { + if ( + typeof remotePath !== 'string' || + remotePath === '' || + remotePath.includes('\0') || + remotePath.includes('\r') || + remotePath.includes('\n') + ) { + throw new Error('SSH relay runtime Windows file destination path is invalid') + } + const normalized = normalizeWindowsRemotePath(remotePath) + const driveRooted = /^[A-Za-z]:\//u.test(normalized) + const uncRooted = /^\/\/[^/]+\/[^/]+\//u.test(normalized) + if ((!driveRooted && !uncRooted) || normalized.endsWith('/')) { + throw new Error('SSH relay runtime Windows file destination path is invalid') + } + const segments = driveRooted ? normalized.slice(3).split('/') : normalized.slice(2).split('/') + try { + for (const segment of segments) { + assertSafeRemotePathSegment(segment, 'windows') + } + } catch { + throw new Error('SSH relay runtime Windows file destination path is invalid') + } + return normalized +} + +function buildHeader(remotePath: string, expectedSize: number): Buffer { + const pathBytes = Buffer.from(remotePath, 'utf8') + if (pathBytes.length === 0 || pathBytes.length > MAXIMUM_PATH_BYTES) { + throw new Error('SSH relay runtime Windows file destination path is invalid') + } + const header = Buffer.allocUnsafe(FIXED_HEADER_BYTES + pathBytes.length) + HEADER_MAGIC.copy(header, 0) + header.writeUInt32LE(pathBytes.length, 8) + header.writeBigUInt64LE(BigInt(expectedSize), 12) + pathBytes.copy(header, FIXED_HEADER_BYTES) + return header +} + +function validateOptions(options: OpenSshRelayRuntimeWindowsFileDestinationOptions): string { + if ( + !options || + typeof options.openChannel !== 'function' || + !options.signal || + !Number.isSafeInteger(options.expectedSize) || + options.expectedSize < 0 + ) { + throw new Error('SSH relay runtime Windows file destination input is invalid') + } + return normalizeRemotePath(options.remotePath) +} + +export async function openSshRelayRuntimeWindowsFileDestination( + options: OpenSshRelayRuntimeWindowsFileDestinationOptions +): Promise { + const remotePath = validateOptions(options) + const header = buildHeader(remotePath, options.expectedSize) + const destination = await openSshRelayRuntimeCommandFileDestination({ + command: RECEIVER_COMMAND, + fileKind: 'Windows', + signal: options.signal, + openChannel: options.openChannel + }) + try { + // Why: the receiver must authenticate framing before any source buffer can be borrowed. + await destination.write(header) + return destination + } catch (error) { + try { + await destination.abort(error) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'SSH relay runtime Windows file header cleanup failed' + ) + } + throw error + } +}