From 72d482201e99975bb0133d1934906a95d2033934 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 16 Jul 2026 10:06:52 -0400 Subject: [PATCH] feat: add bounded SSH relay SFTP 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 | 18 +- ...github-release-implementation-checklist.md | 89 +++++- ...elay-runtime-sftp-file-destination.test.ts | 289 ++++++++++++++++++ ...ssh-relay-runtime-sftp-file-destination.ts | 210 +++++++++++++ 6 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts create mode 100644 src/main/ssh/ssh-relay-runtime-sftp-file-destination.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 3c2b0e75914..7c18c15a716 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -285,6 +285,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-tree.test.ts \ src/main/ssh/ssh-relay-runtime-source-scan.test.ts \ src/main/ssh/ssh-relay-runtime-source-stream.test.ts \ + src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts \ src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts \ src/main/ssh/ssh-relay-official-manifest.test.ts \ src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts \ @@ -840,6 +841,7 @@ jobs: src/main/ssh/ssh-relay-runtime-source-tree.test.ts ` src/main/ssh/ssh-relay-runtime-source-scan.test.ts ` src/main/ssh/ssh-relay-runtime-source-stream.test.ts ` + src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts ` src/main/ssh/ssh-relay-compiled-manifest-trust.test.ts ` src/main/ssh/ssh-relay-official-manifest.test.ts ` src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 3b99c61f563..53ff1bf7bfb 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -143,6 +143,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-runtime-source-tree.test.ts', 'ssh-relay-runtime-source-scan.test.ts', 'ssh-relay-runtime-source-stream.test.ts', + 'ssh-relay-runtime-sftp-file-destination.test.ts', 'ssh-relay-compiled-manifest-trust.test.ts', 'ssh-relay-official-manifest.test.ts', 'ssh-relay-manifest-accepted-keys.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 3bb3dd0b586..e7240664173 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,21 @@ 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 — bounded client source -streaming is exact-head green; the smallest disconnected SFTP adapter audit is next, 2026-07-15, -Codex implementation owner.** +Active checkpoint: **Milestone 6 / Work Package 5 bounded runtime transfer — disconnected SFTP +per-file destination is locally green; exact-head native proof is next, 2026-07-15, Codex +implementation owner.** `E-M6-SFTP-FILE-DESTINATION-AUDIT-001` fixes exclusive `wx` open, callback-bounded +positional writes, explicit POSIX mode repair/handle-stat proof, and joined close-before-unlink +cleanup. It deliberately borrows but does not acquire/end an SFTP session and does not create +directories, clean a whole staging tree, or publish/launch anything. Session-wide cancellation, +full-size live SFTP, product/default wiring, and SignPath remain later gates. +`E-M6-SFTP-FILE-DESTINATION-LOCAL-RED-001` records the expected missing-module failure for the +12-case purpose contract; the 7/7 native workflow oracle already pins it once in both command +families. Protected resolvers remain untouched and no production importer exists. +`E-M6-SFTP-FILE-DESTINATION-LOCAL-001` passes the hardened 13-case purpose suite, 103 focused cases +with one declared skip, 579 broad relay cases with five declared skips, 280/280 workflow cases, +typecheck, full lint, targeted format, max-lines, diff, protected-resolver, and no-product-import +gates. It proves the exact per-file lifecycle only; SFTP session/tree orchestration, live/full-size +remote proof, and every product/default path remain open. `E-M6-SOURCE-STREAM-CI-001` closes exact commit `df39c287d`: all six primary native clients pass the 27-case source-stream suite (Windows has only the declared POSIX-mode skip), exact 85–125 MB trees stream in 103.663–530.937 ms with 0–5,492,736 incremental RSS bytes, both Linux supplements and the 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 ace0e2e78a2..7b27094bb36 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: bounded client source streaming is exact-head green; the smallest disconnected SFTP adapter audit is next**. 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: **BOUNDED SOURCE STREAM EXACT-HEAD GREEN / SFTP ADAPTER AUDIT NEXT — 2026-07-15, Codex implementation owner** — `E-M6-SOURCE-STREAM-CI-001` closes all-six native purpose and exact full-size client-stream proof. The first adjacent computer-use Windows attempt exposed only two unrelated five-second launcher timeouts under `E-M6-SOURCE-STREAM-ADJACENT-CI-RED-001`; its same-SHA failed-job rerun is fully green without changing timeouts or code. Audit the smallest SFTP destination/session boundary before writing its RED tests. Do not add a product caller, settings/fallback wiring, tuple enablement, publication, or default behavior. 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 SFTP per-file destination is locally green; exact-head native proof is next**. 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: **SFTP PER-FILE DESTINATION LOCAL GREEN — 2026-07-15, Codex implementation owner** — `E-M6-SFTP-FILE-DESTINATION-LOCAL-001` passes the hardened 13-case purpose contract, focused/broad/workflow tests, and all static/isolation gates. It proves exclusive remote open, exact callback-bounded offsets, in-flight cancellation joining, explicit POSIX mode repair/handle-stat proof, Windows mode skip, close-before-unlink cleanup, and idempotent aggregation. Commit/push this disconnected package and require exact-head all-six native/adjacent CI before advancing to SFTP session/tree orchestration. The adapter borrows but does not acquire/end an SFTP session and does not create directories or publish/launch a tree; session-wide cancellation and whole-staging cleanup remain later gates. Do not add a product caller, settings/fallback wiring, tuple enablement, publication, or default behavior. 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) @@ -16738,6 +16738,91 @@ runtime source stream destination is incomplete`. The contract step failed after install/launch step, product/Beta/fallback/tuple/publication/default path, and SignPath remain open. Legacy remains the sole production/default path and every tuple remains disabled. +### E-M6-SFTP-FILE-DESTINATION-AUDIT-001 — exclusive bounded SFTP file lifecycle boundary + +- Date/owner: 2026-07-15, Codex implementation owner. +- Exact input boundary: accept only a caller-owned SFTP file primitive set, one already validated + exact remote staging-file path, the signed `0o644` or `0o755` mode, an explicit POSIX-mode flag, + and the same required caller-owned `AbortSignal` used by the source streamer. Do not derive any + remote path from a client-native local path and do not acquire or end the SFTP session here. +- Open/write contract: check cancellation before open; use only exclusive `wx` open; once the handle + exists this adapter owns that new file. Each `write(chunk)` issues exactly one positional SFTP + write and resolves only from its callback, making that callback the source stream's reusable + 64-KiB buffer-lifetime boundary. Writes are sequential, offsets are exact, empty/concurrent/late + writes are rejected, and no chunk/tree concatenation or base64 representation exists. +- Completion contract: for POSIX remotes, apply the signed mode through the owned handle, re-read + handle attributes, and require the exact permission bits before closing. Windows/non-POSIX + remotes skip POSIX mode operations explicitly. A close is successful only after the remote handle + callback settles and a final signal check passes. +- Failure/cancellation contract: an open failure never unlinks because no ownership was obtained. + Any later write, mode, verification, close, or cancellation failure leaves the destination + abortable; abort attempts owned-handle close before unlink, joins cleanup failures, and is + idempotent. Cancellation during an in-flight SFTP callback remains joined until that callback is + forced to settle by the later session owner, preventing reuse of a possibly retained source + buffer. +- Required purpose RED: missing destination module; exclusive open and exact offsets; deferred + callback/buffer lifetime; pre-open and post-open cancellation; write/mode/stat/close failures; + POSIX exact-mode verification; explicit Windows mode skip; no deletion on failed exclusive open; + close-before-unlink order; joined/idempotent cleanup; illegal concurrent/empty/late operations; + and path-free adapter-authored diagnostics. Add the suite exactly once to both native artifact + command families before implementation. +- Explicit residual boundary: this is a per-file primitive, not an SFTP transfer. SFTP session + acquisition/end/error ownership, bounded tree concurrency, remote directory creation, remote-path + policy, session-wide timeout/cancellation, full-staging cleanup, real server behavior, full-size + transfer, mode behavior across servers, and later remote tree verification/install/publish/launch + all remain open. No production importer, setting, fallback, tuple, release, or default path may be + added by this package; legacy remains the sole production path. + +### E-M6-SFTP-FILE-DESTINATION-LOCAL-RED-001 — destination lifecycle is absent + +- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0, + pnpm 10.24.0, atop exact checkpoint commit `705bca2db` plus the purpose/workflow/checklist RED diff. +- Purpose command: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 +src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts` — expected RED, exit 1: one failed + suite, zero collected tests, because `./ssh-relay-runtime-sftp-file-destination` does not exist. + The 260-line source contract declares 12 cases including two parameterized failure cells. +- Native workflow oracle: `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 +config/scripts/ssh-relay-runtime-workflow.test.mjs` — PASS, 7/7 in 356 ms. The workflow source and + oracle already require the purpose suite exactly once in both POSIX and PowerShell native command + families, so implementation cannot become locally green while native coverage remains omitted. +- Static precondition: `git diff --check` passes and the test remains below the max-lines ratchet at + 260 lines. Protected resolver files remain untouched. No production importer, SFTP session owner, + remote directory/tree orchestrator, product caller, setting, fallback, tuple, publication, or + default behavior exists in the RED diff. + +### E-M6-SFTP-FILE-DESTINATION-LOCAL-001 — per-file SFTP lifecycle is locally green + +- Date/owner/environment: 2026-07-15, Codex implementation owner; local macOS arm64, Node 26.0.0, + pnpm 10.24.0, atop exact checkpoint commit `705bca2db` plus the implementation/checklist diff. +- Implementation: the 210-line `ssh-relay-runtime-sftp-file-destination.ts` accepts only the audited + primitive boundary. It opens with `wx` and signed mode, awaits one SFTP positional-write callback + per source chunk before advancing the exact offset, checks the same signal around every callback, + repairs and verifies POSIX permission bits before close, and skips those operations only from the + explicit non-POSIX flag. Adapter-authored state/input errors contain no remote path. +- Purpose command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts` — PASS, 13/13 in + 431 ms Vitest / 2.36 seconds wall, 133,005,312-byte maximum RSS, zero swaps. The additional + hardening case proves cancellation during a pending write cannot settle or begin cleanup until the + SFTP callback releases the borrowed source buffer; it then closes before unlinking. +- Focused command: the destination, source stream/scan/tree, acquisition/cache population and + resolution, integration, and workflow-oracle ten-file command — PASS, 103 tests with one declared + platform skip in 8.28 seconds. +- Broad commands: `src/main/ssh/ssh-relay-*.test.ts` — PASS, 579 tests with five declared platform/ + full-size skips in 27.25 seconds; `config/scripts/ssh-relay-runtime-*.test.mjs` — PASS, 280/280 in + 20.82 seconds. The first quoted-glob invocations matched no files and were discarded as command + syntax mistakes; the unquoted exact checklist commands above are the credited results. +- Static/isolation commands: `pnpm typecheck`, targeted `pnpm exec oxlint`, `pnpm lint`, targeted + `pnpm exec oxfmt --check`, `pnpm run check:max-lines-ratchet`, `git diff --check`, the protected- + resolver diff gate, and the no-non-test-importer `rg` gate pass. Full lint includes switch + exhaustiveness, styled-scrollbar, 41 reliability, bundled-skill, max-lines, and localization gates; + only pre-existing unrelated warnings remain. The module/test are 210/289 lines with no bypass. +- Residual: this is callback-contract proof with mocked primitives, not a live SFTP server, session, + SSH connection, remote directory/staging tree, bounded multi-file transfer, session-wide abort/ + timeout, whole-tree cleanup, or full-size remote measurement. It has no non-test importer. Exact- + head all-six Node 24 and adjacent Actions evidence is required before auditing the session/tree + adapter. Product/Beta/fallback/tuple/publication/default behavior and SignPath remain absent; + legacy remains the sole production path. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit diff --git a/src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts b/src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts new file mode 100644 index 00000000000..61b39bde8e0 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-sftp-file-destination.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + openSshRelayRuntimeSftpFileDestination, + type SshRelayRuntimeSftpFileOperations +} from './ssh-relay-runtime-sftp-file-destination' + +type Callback = (error?: Error) => void + +function createOperations(options: { mode?: number } = {}): { + operations: SshRelayRuntimeSftpFileOperations + events: string[] + handle: Buffer +} { + const handle = Buffer.from('owned-handle') + const events: string[] = [] + const operations = { + open: vi.fn( + ( + _path: string, + _flags: string, + _attributes: { mode: number }, + callback: (error: Error | undefined, value: Buffer) => void + ) => { + events.push('open') + callback(undefined, handle) + } + ), + write: vi.fn( + ( + _handle: Buffer, + _buffer: Buffer, + _offset: number, + _length: number, + _position: number, + callback: Callback + ) => { + events.push('write') + callback() + } + ), + fchmod: vi.fn((_handle: Buffer, _mode: number, callback: Callback) => { + events.push('fchmod') + callback() + }), + fstat: vi.fn( + (_handle: Buffer, callback: (error: Error | undefined, value: { mode: number }) => void) => { + events.push('fstat') + callback(undefined, { mode: options.mode ?? 0o100755 }) + } + ), + close: vi.fn((_handle: Buffer, callback: Callback) => { + events.push('close') + callback() + }), + unlink: vi.fn((_path: string, callback: Callback) => { + events.push('unlink') + callback() + }) + } as unknown as SshRelayRuntimeSftpFileOperations + return { operations, events, handle } +} + +function openDestination( + operations: SshRelayRuntimeSftpFileOperations, + options: { signal?: AbortSignal; enforcePosixMode?: boolean; remotePath?: string } = {} +) { + return openSshRelayRuntimeSftpFileDestination({ + operations, + remotePath: options.remotePath ?? '/owned-staging/bin/node', + mode: 0o755, + enforcePosixMode: options.enforcePosixMode ?? true, + signal: options.signal ?? new AbortController().signal + }) +} + +describe('SSH relay runtime SFTP file destination', () => { + it('opens exclusively and awaits each exact positional write callback', async () => { + const { operations, handle } = createOperations() + let firstCallback: Callback | undefined + vi.mocked(operations.write).mockImplementationOnce( + (_handle, _buffer, _offset, _length, _position, callback) => { + firstCallback = callback + } + ) + + const destination = await openDestination(operations) + const firstChunk = Buffer.from('abc') + let firstSettled = false + const firstWrite = destination.write(firstChunk).then(() => { + firstSettled = true + }) + await Promise.resolve() + expect(firstSettled).toBe(false) + expect(operations.open).toHaveBeenCalledWith( + '/owned-staging/bin/node', + 'wx', + { mode: 0o755 }, + expect.any(Function) + ) + expect(operations.write).toHaveBeenNthCalledWith( + 1, + handle, + firstChunk, + 0, + 3, + 0, + expect.any(Function) + ) + + firstCallback?.() + await firstWrite + const secondChunk = Buffer.from('de') + await destination.write(secondChunk) + expect(operations.write).toHaveBeenNthCalledWith( + 2, + handle, + secondChunk, + 0, + 2, + 3, + expect.any(Function) + ) + }) + + it('repairs and verifies exact POSIX mode before closing the owned handle', async () => { + const { operations, events, handle } = createOperations() + const destination = await openDestination(operations) + await destination.close() + + expect(operations.fchmod).toHaveBeenCalledWith(handle, 0o755, expect.any(Function)) + expect(operations.fstat).toHaveBeenCalledWith(handle, expect.any(Function)) + expect(events).toEqual(['open', 'fchmod', 'fstat', 'close']) + }) + + it('skips POSIX mode operations only when the caller explicitly disables them', async () => { + const { operations, events } = createOperations() + const destination = await openDestination(operations, { enforcePosixMode: false }) + await destination.close() + + expect(events).toEqual(['open', 'close']) + expect(operations.fchmod).not.toHaveBeenCalled() + expect(operations.fstat).not.toHaveBeenCalled() + }) + + it('rejects a mode mismatch and removes the owned incomplete file on abort', async () => { + const { operations, events } = createOperations({ mode: 0o100644 }) + const destination = await openDestination(operations) + await expect(destination.close()).rejects.toThrow(/mode/i) + await destination.abort(new Error('mode mismatch')) + + expect(events).toEqual(['open', 'fchmod', 'fstat', 'close', 'unlink']) + }) + + it.each(['fchmod', 'fstat'] as const)('fails closed when %s fails', async (operation) => { + const { operations } = createOperations() + vi.mocked(operations[operation]).mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as Callback + callback(new Error(`${operation} failed`)) + }) + const destination = await openDestination(operations) + + await expect(destination.close()).rejects.toThrow(`${operation} failed`) + await destination.abort(new Error(`${operation} failed`)) + expect(operations.close).toHaveBeenCalledOnce() + expect(operations.unlink).toHaveBeenCalledOnce() + }) + + it('does not unlink a pre-existing file when exclusive open fails', async () => { + const { operations } = createOperations() + vi.mocked(operations.open).mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error) => void + callback(new Error('already exists')) + }) + + await expect(openDestination(operations)).rejects.toThrow('already exists') + expect(operations.close).not.toHaveBeenCalled() + expect(operations.unlink).not.toHaveBeenCalled() + }) + + it('rejects pre-open cancellation without creating a remote file', async () => { + const { operations } = createOperations() + const controller = new AbortController() + controller.abort(new Error('cancelled before open')) + + await expect(openDestination(operations, { signal: controller.signal })).rejects.toThrow( + 'cancelled before open' + ) + expect(operations.open).not.toHaveBeenCalled() + }) + + it('cleans an exclusively opened file when cancellation wins the open callback', async () => { + const { operations, events, handle } = createOperations() + const controller = new AbortController() + vi.mocked(operations.open).mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: undefined, value: Buffer) => void + controller.abort(new Error('cancelled after open')) + callback(undefined, handle) + }) + + await expect(openDestination(operations, { signal: controller.signal })).rejects.toThrow( + 'cancelled after open' + ) + expect(events).toEqual(['close', 'unlink']) + }) + + it('joins cancellation to the in-flight write callback before cleanup', async () => { + const { operations, events } = createOperations() + const controller = new AbortController() + let writeCallback: Callback | undefined + vi.mocked(operations.write).mockImplementationOnce( + (_handle, _buffer, _offset, _length, _position, callback) => { + events.push('write') + writeCallback = callback + } + ) + const destination = await openDestination(operations, { signal: controller.signal }) + let settled = false + const write = destination.write(Buffer.from('bytes')).finally(() => { + settled = true + }) + + controller.abort(new Error('cancelled during write')) + await Promise.resolve() + expect(settled).toBe(false) + expect(operations.close).not.toHaveBeenCalled() + writeCallback?.() + await expect(write).rejects.toThrow('cancelled during write') + await destination.abort(controller.signal.reason) + expect(events).toEqual(['open', 'write', 'close', 'unlink']) + }) + + it('propagates a write failure and then closes before unlinking', async () => { + const { operations, events } = createOperations() + vi.mocked(operations.write).mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as Callback + events.push('write') + callback(new Error('remote write failed')) + }) + const destination = await openDestination(operations) + + await expect(destination.write(Buffer.from('bytes'))).rejects.toThrow('remote write failed') + await destination.abort(new Error('remote write failed')) + expect(events).toEqual(['open', 'write', 'close', 'unlink']) + }) + + it('joins close and unlink cleanup failures and makes abort idempotent', async () => { + const { operations } = createOperations() + vi.mocked(operations.close).mockImplementation((_handle, callback) => + callback(new Error('close cleanup failed')) + ) + vi.mocked(operations.unlink).mockImplementation((_path, callback) => + callback(new Error('unlink cleanup failed')) + ) + const destination = await openDestination(operations) + + const firstAbort = destination.abort(new Error('primary')) + const secondAbort = destination.abort(new Error('secondary')) + expect(firstAbort).toBe(secondAbort) + await expect(firstAbort).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ message: 'close cleanup failed' }), + expect.objectContaining({ message: 'unlink cleanup failed' }) + ] + }) + expect(operations.close).toHaveBeenCalledOnce() + expect(operations.unlink).toHaveBeenCalledOnce() + }) + + it('rejects empty, concurrent, and late writes with path-free diagnostics', async () => { + const { operations } = createOperations() + let writeCallback: Callback | undefined + vi.mocked(operations.write).mockImplementationOnce( + (_handle, _buffer, _offset, _length, _position, callback) => { + writeCallback = callback + } + ) + const secretPath = '/home/private-user/staging/node' + const destination = await openDestination(operations, { remotePath: secretPath }) + + await expect(destination.write(Buffer.alloc(0))).rejects.not.toThrow(secretPath) + const pending = destination.write(Buffer.from('first')) + await expect(destination.write(Buffer.from('second'))).rejects.toThrow(/concurrent/i) + writeCallback?.() + await pending + await destination.close() + await expect(destination.write(Buffer.from('late'))).rejects.toThrow(/closed/i) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-sftp-file-destination.ts b/src/main/ssh/ssh-relay-runtime-sftp-file-destination.ts new file mode 100644 index 00000000000..2d62d610975 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-sftp-file-destination.ts @@ -0,0 +1,210 @@ +import type { SshRelayRuntimeSourceDestination } from './ssh-relay-runtime-source-stream' + +type SftpCallback = (error?: Error) => void + +export type SshRelayRuntimeSftpFileOperations = Readonly<{ + open: ( + path: string, + flags: 'wx', + attributes: { mode: number }, + callback: (error: Error | undefined, handle: Buffer) => void + ) => void + write: ( + handle: Buffer, + buffer: Buffer, + offset: number, + length: number, + position: number, + callback: SftpCallback + ) => void + fchmod: (handle: Buffer, mode: number, callback: SftpCallback) => void + fstat: ( + handle: Buffer, + callback: (error: Error | undefined, attributes: { mode: number }) => void + ) => void + close: (handle: Buffer, callback: SftpCallback) => void + unlink: (path: string, callback: SftpCallback) => void +}> + +export type OpenSshRelayRuntimeSftpFileDestinationOptions = Readonly<{ + operations: SshRelayRuntimeSftpFileOperations + remotePath: string + mode: 0o644 | 0o755 + enforcePosixMode: boolean + signal: AbortSignal +}> + +type DestinationState = 'open' | 'closed' | 'complete' | 'aborting' | 'aborted' + +function waitForSftpCallback(register: (callback: SftpCallback) => void): Promise { + return new Promise((resolve, reject) => { + register((error) => (error ? reject(error) : resolve())) + }) +} + +function waitForSftpValue( + register: (callback: (error: Error | undefined, value: T) => void) => void +): Promise { + return new Promise((resolve, reject) => { + register((error, value) => (error ? reject(error) : resolve(value))) + }) +} + +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 SFTP file cleanup failed') +} + +function validateOptions(options: OpenSshRelayRuntimeSftpFileDestinationOptions): void { + if (!options.operations || typeof options.remotePath !== 'string' || options.remotePath === '') { + throw new Error('SSH relay runtime SFTP file destination input is invalid') + } + if (options.remotePath.includes('\0')) { + throw new Error('SSH relay runtime SFTP file destination path is invalid') + } + if (options.mode !== 0o644 && options.mode !== 0o755) { + throw new Error('SSH relay runtime SFTP file destination mode is invalid') + } +} + +function createDestination( + options: OpenSshRelayRuntimeSftpFileDestinationOptions, + handle: Buffer +): SshRelayRuntimeSourceDestination { + const { operations, remotePath, mode, enforcePosixMode, signal } = options + let state: DestinationState = 'open' + let position = 0 + let activeOperation: Promise | undefined + let abortPromise: Promise | undefined + + const assertWritable = (): void => { + if (state !== 'open') { + throw new Error('SSH relay runtime SFTP file destination is closed') + } + if (activeOperation) { + throw new Error('SSH relay runtime SFTP 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 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 SFTP file destination chunk is empty')) + } + return runExclusive(async () => { + signal.throwIfAborted() + // Why: the callback is the remote-consumption boundary; resolving earlier could let the + // source worker reuse its one buffer while ssh2 still retains the chunk view. + await waitForSftpCallback((callback) => + operations.write(handle, chunk, 0, chunk.length, position, callback) + ) + position += chunk.length + signal.throwIfAborted() + }) + } + + const close = (): Promise => { + try { + assertWritable() + } catch (error) { + return Promise.reject(error) + } + return runExclusive(async () => { + signal.throwIfAborted() + if (enforcePosixMode) { + await waitForSftpCallback((callback) => operations.fchmod(handle, mode, callback)) + signal.throwIfAborted() + const attributes = await waitForSftpValue<{ mode: number }>((callback) => + operations.fstat(handle, callback) + ) + if (!Number.isInteger(attributes.mode) || (attributes.mode & 0o777) !== mode) { + throw new Error('SSH relay runtime SFTP file mode verification failed') + } + } + signal.throwIfAborted() + await waitForSftpCallback((callback) => operations.close(handle, callback)) + state = 'closed' + signal.throwIfAborted() + state = 'complete' + }) + } + + const performAbort = async (): Promise => { + if (state === 'complete' || state === 'aborted') { + return + } + const pending = activeOperation + if (pending) { + await pending.catch(() => {}) + } + const handleNeedsClose = state !== 'closed' + state = 'aborting' + const failures: unknown[] = [] + if (handleNeedsClose) { + await waitForSftpCallback((callback) => operations.close(handle, callback)).catch((error) => + failures.push(error) + ) + } + await waitForSftpCallback((callback) => operations.unlink(remotePath, callback)).catch( + (error) => failures.push(error) + ) + state = 'aborted' + const failure = cleanupFailure(failures) + if (failure !== undefined) { + throw failure + } + } + + const abort = (_reason: unknown): Promise => { + abortPromise ??= performAbort() + return abortPromise + } + + return Object.freeze({ write, close, abort }) +} + +export async function openSshRelayRuntimeSftpFileDestination( + options: OpenSshRelayRuntimeSftpFileDestinationOptions +): Promise { + validateOptions(options) + options.signal.throwIfAborted() + const handle = await waitForSftpValue((callback) => + options.operations.open(options.remotePath, 'wx', { mode: options.mode }, callback) + ) + const destination = createDestination(options, handle) + try { + options.signal.throwIfAborted() + return destination + } catch (error) { + try { + await destination.abort(error) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'SSH relay runtime SFTP file open cancellation cleanup failed' + ) + } + throw error + } +}