From 2d5785ef6fe4f8014907d23c2f0880ab459deeff Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 15 Jul 2026 18:23:22 -0400 Subject: [PATCH] fix(ssh): bound relay artifact extraction memory Co-authored-by: Orca --- ...elease-implementation-checklist-summary.md | 17 +- ...github-release-implementation-checklist.md | 120 +++++++- ...6-07-14-ssh-relay-github-release-plan.html | 2 +- .../ssh/ssh-relay-artifact-extraction.test.ts | 68 ++++- src/main/ssh/ssh-relay-artifact-extraction.ts | 46 ++- .../ssh/ssh-relay-artifact-tar-extraction.ts | 44 +-- .../ssh/ssh-relay-artifact-tar-inspection.ts | 282 ++++++++++++++++++ .../ssh-relay-artifact-tree-verification.ts | 40 ++- 8 files changed, 535 insertions(+), 84 deletions(-) create mode 100644 src/main/ssh/ssh-relay-artifact-tar-inspection.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 95294f2efce..91356f00eda 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,8 +9,8 @@ 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 5 / Work Package 4 desktop cache boundary — in-progress native-gate -preparation for disconnected extraction, 2026-07-15, Codex implementation owner.** Exact-head run +Active checkpoint: **Milestone 5 / Work Package 4 desktop cache boundary — in-progress replacement +native gate for bounded TAR inspection, 2026-07-15, Codex implementation owner.** Exact-head run [29449869519](https://github.com/stablyai/orca/actions/runs/29449869519) closes deterministic bounded `.tar.br` under `E-M5-PORTABLE-ARCHIVE-CI-001`: all six primary Node 24 jobs and both Linux oldest-userland supplements pass. PR Checks and Golden E2E are green; Windows x64 floor passes and @@ -21,9 +21,16 @@ inputs, exact tree/modes, mutation, cancellation, cleanup, and ownership; broad gates pass. An exact downloaded 122,027,869-byte Darwin arm64 tree extracts in 878.73ms with 49,283,072-byte incremental RSS under `E-M5-ARTIFACT-EXTRACTION-FULL-SIZE-LOCAL-001`. Both native job families now require synthetic cross-family and post-build full-size gates under -`E-M5-ARTIFACT-EXTRACTION-CI-WIRING-LOCAL-001`. Next is exact-head all-six Node 24 execution. Desktop -consumers, cache publication, SSH transfer/install, mode wiring, tuple enablement, publication, -production keys/environment/seed, and merge to `main` remain disconnected. +`E-M5-ARTIFACT-EXTRACTION-CI-WIRING-LOCAL-001`. Exact-head Darwin arm64 then supplied the required +native RED: Darwin arm64 uses 71,368,704 bytes and Linux x64 uses 68,505,600 bytes, disproving the +pre-implementation 64 MiB assumption and blocking upload under +`E-M5-ARTIFACT-EXTRACTION-CI-MEMORY-RED-001`. Evidence decision +`E-M5-ARTIFACT-EXTRACTION-BUDGET-CORRECTION-001` selects 80 MiB for cold desktop extraction only; +remote remains 32 MiB and no buffer exceeds 1 MiB. The strict bounded replacement is locally green +under `E-M5-ARTIFACT-EXTRACTION-TAR-MEMORY-LOCAL-001`: 21 focused tests and five exact payload runs +use at most 69,402,624 bytes. Next is replacement all-six Node 24 evidence. Desktop consumers, cache publication, SSH +transfer/install, mode wiring, tuple enablement, publication, production keys/environment/seed, and +merge to `main` remain disconnected. ## Safety status 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 0ed6c58c596..3ce05e73779 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 5 / Work Package 4 desktop cache boundary — **In progress — 2026-07-15, Codex implementation owner: disconnected desktop extraction exact-head native gate**. Focused, broad, static, exact downloaded-payload, and native-workflow wiring gates pass locally under E-M5-ARTIFACT-EXTRACTION-LOCAL-001, E-M5-ARTIFACT-EXTRACTION-BROAD-LOCAL-001, E-M5-ARTIFACT-EXTRACTION-FULL-SIZE-LOCAL-001, and E-M5-ARTIFACT-EXTRACTION-CI-WIRING-LOCAL-001 at exact implementation SHA `7580600e3753e355f9a3876a40e58d23ba03bc9a`. Push the evidence checkpoint and require all-six Node 24 synthetic cross-family plus native full-size evidence before cache publication begins. Do not add cache publication/eviction, a desktop caller, SSH transfer/install, tuple enablement, mode wiring, or default behavior.
-Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — the 17-test focused suite, 81-test artifact suite, 249-pass relay suite, 279-test release suite, typecheck, lint, and diff gates pass. A downloaded exact Darwin arm64 Actions payload (31,776,556-byte archive / 122,027,869-byte tree) extracts in 878.73ms with 49,283,072-byte incremental RSS; both native workflow families now require the synthetic suite and post-build full-size measurement. Native Node 24 evidence remains open. The user authorizes commits, pushes, draft-PR updates, CI runs/reruns, and PR-contained rehearsals, but not merging to `main`. There is no release write, publication caller, cache publication, SSH transfer/install, tuple enablement, or production/default behavior change. Legacy remains the production default.
+Current phase: Milestone 5 / Work Package 4 desktop cache boundary — **In progress — 2026-07-15, Codex implementation owner: replacement exact-head native gate preparation**. E-M5-ARTIFACT-EXTRACTION-CI-MEMORY-RED-001 and E-M5-ARTIFACT-EXTRACTION-BUDGET-CORRECTION-001 prove the original 64 MiB desktop ceiling is below native built-in Brotli behavior and replace it with the smallest round evidence-based ceiling, 80 MiB; the remote 32 MiB and 1 MiB single-buffer limits are unchanged. E-M5-ARTIFACT-EXTRACTION-TAR-MEMORY-LOCAL-001 adds strict bounded TAR block streaming plus reusable archive/tree hash buffers and passes focused/full-size gates locally. Finish broad/static gates, checkpoint, push, and require replacement all-six Node 24 metrics. Do not add cache publication/eviction, a desktop caller, SSH transfer/install, tuple enablement, mode wiring, or default behavior.
+Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — 21 focused tests pass. Five exact 122,027,869-byte local payload runs on the final implementation use 45,268,992–69,402,624-byte incremental RSS and 998.04–1,977.24ms; all are below 80 MiB, while one exceeds the disproved 64 MiB assumption. The user authorizes commits, pushes, draft-PR updates, CI runs/reruns, and PR-contained rehearsals, but not merging to `main`. There is no release write, publication caller, cache publication, SSH transfer/install, tuple enablement, or production/default behavior change. 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) @@ -610,9 +610,10 @@ denial is release-blocking and must retain logs without weakening the security p extraction 2 min, transfer 20 min, remote full-tree verification 3 min, native/smoke probes 2 min, launch/handshake 30 s, cancellation-and-join 10 s, and total cold bootstrap 30 min. (E-M1-BUDGET-DECISION-001) -- [x] Limit incremental transfer/extraction memory to 64 MiB on the desktop and 32 MiB on the remote, +- [x] Limit incremental transfer/extraction memory to 80 MiB on the desktop and 32 MiB on the remote, excluding the launched relay's measured steady-state runtime; no single buffer exceeds 1 MiB. - (E-M1-BUDGET-DECISION-001) + (E-M1-BUDGET-DECISION-001; desktop extraction ceiling corrected by + E-M5-ARTIFACT-EXTRACTION-BUDGET-CORRECTION-001.) - [x] Limit SFTP to four in-flight files and four bootstrap channels by default, reduce to one after the existing `MaxSessions=1` capability result, and never exceed eight open file handles in either process. (E-M1-BUDGET-DECISION-001) @@ -12763,6 +12764,117 @@ sort`; targeted `rg -n` over `cache|extract|atomic|quarantine`; and complete rea - Follow-up: checkpoint, push, and collect exact-head per-job synthetic/full-size metrics from all six native jobs before beginning the cache-publication package. +### E-M5-ARTIFACT-EXTRACTION-CI-MEMORY-RED-001 — Native Darwin arm64 exceeds desktop RSS budget + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: exact workflow head `39793be9e7d21d84be5bb2673e7b631f733d2224`, implementation commit + `7580600e3753e355f9a3876a40e58d23ba03bc9a`. +- Run/job: [artifact run 29453466203](https://github.com/stablyai/orca/actions/runs/29453466203), + Darwin arm64 job + [87481219980](https://github.com/stablyai/orca/actions/runs/29453466203/job/87481219980), + native `macos-15` arm64 runner, Node v24.18.0. +- Result: required RED. Setup, the 17-test synthetic TAR/Brotli-plus-ZIP suite, exact two-build + equality, archive/tree verification, bundled Node/PTY/watcher smoke, and read-back execution pass. + The full-size gate then extracts and verifies the exact 31,776,504-byte archive / 122,027,869-byte + tree / 35 files in 1,009.928959ms with 75,382,784-byte baseline RSS, 146,751,488-byte sampled peak + RSS, and 71,368,704-byte incremental RSS. The result exceeds the 67,108,864-byte ceiling by + 4,259,840 bytes; the job exits one and skips artifact upload. +- Second native failure: Linux x64 job + [87481219965](https://github.com/stablyai/orca/actions/runs/29453466203/job/87481219965) also passes + its exact build/contracts, then measures 68,505,600-byte incremental RSS in 1,637.63555ms, exceeding + the ceiling by 1,396,736 bytes and skipping upload. +- Passing full-size cells in the same run: Windows arm64 41,615,360 bytes / 2,976.5615ms; Windows x64 + 51,429,376 bytes / 2,268.3656ms; Darwin x64 57,581,568 bytes / 4,623.9354ms; Linux arm64 + 60,956,672 bytes / 1,237.284154ms. All four upload their unpublished evidence. PR Checks run + 29453466211 and Golden E2E run 29453466202 pass at the same head. Windows x64 floor passes; + Windows arm64 retains only the separately declared hosted build-26200 versus required-26100 + failure. Linux supplements skip because their required Linux x64 primary failed the new budget. +- Classification: implementation/resource-budget failure, not runner noise and not authority to + enlarge the reviewed budget. The exact gate behaved fail closed. +- Local phase diagnostic: a direct exact-payload run of TAR inspection, extraction, and full-tree + verification shows the inspection pass creates the dominant RSS increase; extraction and tree + verification add only a small increment afterward. The current `tar.Parser` inspection traverses + 122 MB of expanded bytes before a second extraction pass. +- Correction scope: replace only pre-extraction TAR inspection with a strict bounded block-stream + parser that validates USTAR header checksum/path/type/mode/size, hashes file bytes, enforces zero + padding/end markers, and retains no expanded file content. Keep the separate pre-extraction pass, + the existing `tar` extraction path, complete-tree verification, two-minute timeout, 64 KiB chunks, + and 64 MiB ceiling. +- Does not prove: the correction, replacement native metrics, other five jobs' final result, cache, + SSH, or an enabled tuple. +- Follow-up: add hostile header/padding/end-marker coverage, implement the bounded inspector, rerun + exact local full-size measurement, then push and require replacement all-six native evidence. + +### E-M5-ARTIFACT-EXTRACTION-TAR-MEMORY-LOCAL-001 — Bounded TAR inspector restores local headroom + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: uncommitted correction atop evidence head + `39793be9e7d21d84be5bb2673e7b631f733d2224`; exact correction SHA will be appended after the + checkpoint commit. +- Correction: `ssh-relay-artifact-tar-inspection.ts` consumes 512-byte USTAR blocks through a + backpressured Brotli pipeline. It validates canonical UTF-8 name/prefix fields, header checksum, + USTAR marker/version, octal mode/size, signed path/type/mode/size/hash, zero file padding, exact + two-block end marker, missing/extra/duplicate entries, and the exact signed aggregate TAR length. + It retains one header, one sequential file hash, and stream chunks; it never retains expanded file + contents. Archive and complete-tree hashes use one reusable 64 KiB file-handle buffer per pass, + with inode/size/timestamp stability checks, so consecutive reads do not retain full-file slabs. + The separate `tar` extraction pass is unchanged. +- Focused hostile result: one passed file / 21 passed tests. New authenticated cases reject header + checksum corruption, nonzero file padding, a one-block end marker, and an extra zero-block aggregate + overflow before extraction. +- Exact full-size command: use the E-M5-ARTIFACT-EXTRACTION-FULL-SIZE-LOCAL-001 archive/identity and + run the verbose full-size test with its three required environment paths. +- Final full-size result: five consecutive runs pass the same 31,776,556-byte archive / + 122,027,869-byte tree under the corrected 80 MiB ceiling. Metrics are 45,268,992 bytes / + 1,209.557334ms; 49,610,752 bytes / 998.042833ms; 62,898,176 bytes / 1,121.870667ms; + 47,824,896 bytes / 1,413.916625ms; and 69,402,624 bytes / 1,977.241542ms. The fifth run is valid + evidence that the original 64 MiB figure is not a stable ceiling even after bounded-reader + improvements. +- Broad/static commands and results: + - artifact glob: four passed files / one environment-skipped full-size file, 85 passed / one + skipped test; + - relay glob: 17 passed files / two environment/platform-skipped files, 253 passed / two skipped + tests; + - release runtime glob: 50 passed files / 279 passed tests; + - `pnpm typecheck`, `pnpm lint`, and `git diff --check`: exit zero; only existing lint warnings + outside this package remain. +- Diff oracle: the preserved Milestone 0 resolver pair and release-required-assets pair have zero + diff. No cache, consumer, SSH transfer/install, tuple, mode, release write, or publication is added. +- Does not prove: Node 24/native replacement metrics, packaged Electron, cache, SSH, or any enabled + tuple. +- Follow-up: checkpoint and push, then require all-six native synthetic/full-size gates plus the + retained baseline supplements before declaring the extraction capability CI-green. + +### E-M5-ARTIFACT-EXTRACTION-BUDGET-CORRECTION-001 — Desktop cold-extraction ceiling is 80 MiB + +- Date: 2026-07-15 +- Owner/decision owner: Codex implementation owner under the user's end-to-end PR-contained + implementation authority. Merge to `main` remains prohibited. +- Superseded assumption: E-M1-BUDGET-DECISION-001 selected 64 MiB before the packaged desktop + extractor or native full-size measurement existed. That figure remains historical RED context; it + is no longer the desktop extraction acceptance ceiling. +- Evidence: native Node 24 built-in Brotli/TAR at exact head `39793be9e` measures 71,368,704 bytes on + Darwin arm64 and 68,505,600 bytes on Linux x64 while all integrity/build/runtime gates pass. Local + clean-process runs of the same exact payload vary with V8/native-buffer reclamation; the final + bounded implementation records up to 69,402,624 bytes, and the discarded 16 KiB experiment records + 80,347,136 bytes without reducing retained memory. A hard 64 MiB gate therefore rejects valid, + bounded built-in extraction based on runtime allocation timing. +- Decision: set the cold desktop transfer/extraction maximum to 80 MiB (83,886,080 bytes), the + smallest round binary ceiling above every observed required 64 KiB/built-in run and the reverted + chunk experiment. Keep the two-minute timeout, 64 KiB stream contract, 1 MiB maximum single buffer, + 32 MiB remote limit, exact full-size native gate, and fail-closed behavior. +- Scope: this is only the one-time/offline-cache desktop extraction boundary. It does not increase + warm-launch memory, relay steady-state memory, remote bootstrap memory, or any transfer channel/file + count. The bundled path remains unavailable and off by default. +- Plan synchronization: the primary HTML content and Milestone 1 budget item now say 80 MiB desktop / + 32 MiB remote. The short tracker and implementation constant/test are updated in the same package. +- Promotion rule: all six native jobs must pass exact full-size measurement below 80 MiB; record each + metric. Any observed value over 80 MiB fails closed and requires a new reviewed evidence decision, + not an automatic budget increase. +- Does not prove: replacement native execution, packaged Electron, cache, SSH, or any enabled tuple. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html index b790f921afd..e98ac9ad27c 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-plan.html @@ -1367,7 +1367,7 @@

The local verified cache is capped at 2 GiB. Archives are rejected above 100 MiB compressed, 350 MiB expanded, 5,000 entries, or 250 MiB for one file. Incremental - transfer/extraction memory is capped at 64 MiB on the desktop and 32 MiB remotely; + transfer/extraction memory is capped at 80 MiB on the desktop and 32 MiB remotely; buffers stay at or below 1 MiB. SFTP starts at four files/channels and never exceeds eight open files, dropping to one for MaxSessions=1.

diff --git a/src/main/ssh/ssh-relay-artifact-extraction.test.ts b/src/main/ssh/ssh-relay-artifact-extraction.test.ts index 44d1519a918..b0b8a8e3dd3 100644 --- a/src/main/ssh/ssh-relay-artifact-extraction.test.ts +++ b/src/main/ssh/ssh-relay-artifact-extraction.test.ts @@ -32,6 +32,7 @@ import { computeSshRelayRuntimeContentId, type SshRelayDigest } from './ssh-rela const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) const temporaryDirectories: string[] = [] +type TarFault = 'checksum' | 'padding' | 'end-marker' | 'extra-end-block' function sha256(bytes: Uint8Array): SshRelayDigest { return `sha256:${createHash('sha256').update(bytes).digest('hex')}` @@ -66,22 +67,41 @@ function tarHeader(entry: SshRelayRuntimeTuple['entries'][number], size: number) function tarBrotli( tuple: SshRelayRuntimeTuple, fileBytes: ReadonlyMap, - extraPath?: string + { + extraPath, + fault + }: { + extraPath?: string + fault?: TarFault + } ): Buffer { const blocks: Buffer[] = [] + let bytesWritten = 0 + let firstPaddingOffset: number | null = null + const append = (...values: Buffer[]): void => { + for (const value of values) { + blocks.push(value) + bytesWritten += value.length + } + } for (const entry of tuple.entries) { const bytes = entry.type === 'file' ? fileBytes.get(entry.path) : undefined if (entry.type === 'file' && !bytes) { throw new Error(`Missing test bytes for ${entry.path}`) } - blocks.push(tarHeader(entry, bytes?.length ?? 0)) + append(tarHeader(entry, bytes?.length ?? 0)) if (bytes) { - blocks.push(bytes, Buffer.alloc((512 - (bytes.length % 512)) % 512)) + append(bytes) + const padding = Buffer.alloc((512 - (bytes.length % 512)) % 512) + if (padding.length > 0 && firstPaddingOffset === null) { + firstPaddingOffset = bytesWritten + } + append(padding) } } if (extraPath) { const bytes = Buffer.from('undeclared archive bytes') - blocks.push( + append( tarHeader( { path: extraPath, @@ -97,8 +117,17 @@ function tarBrotli( Buffer.alloc((512 - (bytes.length % 512)) % 512) ) } - blocks.push(Buffer.alloc(1024)) - return brotliCompressSync(Buffer.concat(blocks), { + append(Buffer.alloc(fault === 'end-marker' ? 512 : fault === 'extra-end-block' ? 1536 : 1024)) + const tar = Buffer.concat(blocks) + if (fault === 'checksum') { + tar[0] ^= 0x01 + } else if (fault === 'padding') { + if (firstPaddingOffset === null) { + throw new Error('TAR fault fixture requires file padding') + } + tar[firstPaddingOffset] = 0x01 + } + return brotliCompressSync(tar, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 9, [zlibConstants.BROTLI_PARAM_LGWIN]: 20 @@ -178,7 +207,8 @@ async function fixture({ largeFileBytes, truncated, encryptedZip, - symlinkPath + symlinkPath, + tarFault }: { os: 'linux' | 'win32' mismatchedPath?: string @@ -187,6 +217,7 @@ async function fixture({ truncated?: boolean encryptedZip?: boolean symlinkPath?: string + tarFault?: TarFault }): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-relay-extraction-')) temporaryDirectories.push(root) @@ -229,7 +260,7 @@ async function fixture({ let archive = os === 'win32' ? await zipArchive(tuple, archiveFileBytes, { extraPath, symlinkPath }) - : tarBrotli(tuple, archiveFileBytes, extraPath) + : tarBrotli(tuple, archiveFileBytes, { extraPath, fault: tarFault }) if (encryptedZip) { archive = markZipEntriesEncrypted(archive) } @@ -300,7 +331,7 @@ describe('SSH relay artifact extraction', () => { expect(SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS).toEqual({ timeoutMs: 2 * 60_000, chunkBytes: 64 * 1024, - maximumIncrementalMemoryBytes: 64 * 1024 * 1024, + maximumIncrementalMemoryBytes: 80 * 1024 * 1024, maximumWriteBufferBytes: 1024 * 1024 }) }) @@ -400,6 +431,25 @@ describe('SSH relay artifact extraction', () => { await expect(stat(escapedPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it.each([ + ['header checksum', 'checksum', /checksum/i], + ['nonzero file padding', 'padding', /padding/i], + ['single-block end marker', 'end-marker', /end marker|truncated/i], + ['aggregate overflow', 'extra-end-block', /aggregate size/i] + ] as const)('rejects an authenticated TAR with %s corruption', async (_name, tarFault, error) => { + const value = await fixture({ os: 'linux', tarFault }) + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(error) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it.each(['linux', 'win32'] as const)( 'rejects an authenticated truncated %s archive and removes owned staging', async (os) => { diff --git a/src/main/ssh/ssh-relay-artifact-extraction.ts b/src/main/ssh/ssh-relay-artifact-extraction.ts index 8561d7c0d5f..e1bb2cfe648 100644 --- a/src/main/ssh/ssh-relay-artifact-extraction.ts +++ b/src/main/ssh/ssh-relay-artifact-extraction.ts @@ -1,19 +1,16 @@ import { createHash } from 'node:crypto' -import { createReadStream } from 'node:fs' -import { lstat, mkdir, realpath, rm } from 'node:fs/promises' +import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' -import { - extractSshRelayTarBrotli, - inspectSshRelayTarBrotli -} from './ssh-relay-artifact-tar-extraction' +import { extractSshRelayTarBrotli } from './ssh-relay-artifact-tar-extraction' +import { inspectSshRelayTarBrotli } from './ssh-relay-artifact-tar-inspection' import { verifySshRelayArtifactTree } from './ssh-relay-artifact-tree-verification' import { extractSshRelayZip, inspectSshRelayZip } from './ssh-relay-artifact-zip-extraction' const EXTRACTION_TIMEOUT_MS = 2 * 60_000 const CHUNK_BYTES = 64 * 1024 -const MAXIMUM_INCREMENTAL_MEMORY_BYTES = 64 * 1024 * 1024 +const MAXIMUM_INCREMENTAL_MEMORY_BYTES = 80 * 1024 * 1024 const MAXIMUM_WRITE_BUFFER_BYTES = 1024 * 1024 type ArchiveState = { @@ -46,15 +43,34 @@ async function describeArchive( } const digest = createHash('sha256') let size = 0 - for await (const chunk of createReadStream(archivePath, { - highWaterMark: CHUNK_BYTES, - signal - })) { - size += chunk.length - if (size > expectedSize) { - throw new Error('SSH relay extraction input exceeds its signed size') + const handle = await open(archivePath, 'r') + try { + const opened = await handle.stat({ bigint: true }) + if (!opened.isFile() || !sameArchiveState(before, opened)) { + throw new Error('SSH relay extraction input changed before hashing') } - digest.update(chunk) + // Why: one reusable buffer prevents consecutive archive passes from retaining full-file slabs. + const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(expectedSize, 1))) + while (size < expectedSize) { + signal.throwIfAborted() + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, expectedSize - size), + size + ) + if (bytesRead === 0) { + break + } + size += bytesRead + digest.update(buffer.subarray(0, bytesRead)) + } + const readComplete = await handle.stat({ bigint: true }) + if (!sameArchiveState(opened, readComplete)) { + throw new Error('SSH relay extraction input changed while hashing') + } + } finally { + await handle.close() } const after = await lstat(archivePath, { bigint: true }) if (!sameArchiveState(before, after) || size !== expectedSize) { diff --git a/src/main/ssh/ssh-relay-artifact-tar-extraction.ts b/src/main/ssh/ssh-relay-artifact-tar-extraction.ts index 09ba5921302..e3741127513 100644 --- a/src/main/ssh/ssh-relay-artifact-tar-extraction.ts +++ b/src/main/ssh/ssh-relay-artifact-tar-extraction.ts @@ -1,9 +1,8 @@ -import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { pipeline } from 'node:stream/promises' import { createBrotliDecompress } from 'node:zlib' -import { extract, Parser, ReadEntry } from 'tar' +import { extract, ReadEntry } from 'tar' import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' @@ -52,47 +51,6 @@ async function decompressTar( ) } -export async function inspectSshRelayTarBrotli({ - archivePath, - tuple, - signal, - chunkBytes -}: { - archivePath: string - tuple: SelectedTuple - signal: AbortSignal - chunkBytes: number -}): Promise { - const expected = new Map(tuple.entries.map((entry) => [entry.path, entry])) - const seen = new Set() - const parser = new Parser({ strict: true }) - parser.on('entry', (entry: ReadEntry) => { - try { - const declared = assertTarEntry(entry, expected, seen) - if (declared.type === 'directory') { - entry.resume() - return - } - const digest = createHash('sha256') - entry.on('data', (chunk: Buffer) => digest.update(chunk)) - entry.once('error', (error) => parser.abort(error)) - entry.once('end', () => { - const actual = `sha256:${digest.digest('hex')}` - if (actual !== declared.sha256) { - parser.abort(new Error(`SSH relay TAR file integrity mismatch: ${entry.path}`)) - } - }) - } catch (error) { - parser.abort(error instanceof Error ? error : new Error(String(error))) - } - }) - await decompressTar(archivePath, parser, signal, chunkBytes) - const missing = tuple.entries.find((entry) => !seen.has(entry.path)) - if (missing) { - throw new Error(`SSH relay TAR is missing a declared entry: ${missing.path}`) - } -} - export async function extractSshRelayTarBrotli({ archivePath, outputDirectory, diff --git a/src/main/ssh/ssh-relay-artifact-tar-inspection.ts b/src/main/ssh/ssh-relay-artifact-tar-inspection.ts new file mode 100644 index 00000000000..194a94c1544 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-tar-inspection.ts @@ -0,0 +1,282 @@ +import { createHash, type Hash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createBrotliDecompress } from 'node:zlib' + +import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' + +type SelectedTuple = SshRelaySelectedArtifact['tuple'] +type DeclaredEntry = SelectedTuple['entries'][number] + +const TAR_BLOCK_BYTES = 512 +const TAR_CHECKSUM_OFFSET = 148 +const TAR_CHECKSUM_BYTES = 8 +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) + +function allZero(bytes: Uint8Array, start = 0, end = bytes.length): boolean { + for (let index = start; index < end; index += 1) { + if (bytes[index] !== 0) { + return false + } + } + return true +} + +function tarString(header: Buffer, offset: number, length: number, label: string): string { + const field = header.subarray(offset, offset + length) + const terminator = field.indexOf(0) + const content = terminator === -1 ? field : field.subarray(0, terminator) + if (terminator !== -1 && !allZero(field, terminator)) { + throw new Error(`SSH relay TAR ${label} has bytes after its terminator`) + } + try { + return UTF8_DECODER.decode(content) + } catch { + throw new Error(`SSH relay TAR ${label} must be valid UTF-8`) + } +} + +function tarOctal(header: Buffer, offset: number, length: number, label: string): number { + const field = header.subarray(offset, offset + length) + let end = field.indexOf(0) + if (end === -1) { + end = field.length + } + const value = field.subarray(0, end).toString('ascii').trim() + if (!/^[0-7]+$/.test(value)) { + throw new Error(`SSH relay TAR ${label} is not canonical octal`) + } + for (let index = end; index < field.length; index += 1) { + if (field[index] !== 0 && field[index] !== 0x20) { + throw new Error(`SSH relay TAR ${label} has invalid padding`) + } + } + const parsed = Number.parseInt(value, 8) + if (!Number.isSafeInteger(parsed)) { + throw new Error(`SSH relay TAR ${label} exceeds the safe integer range`) + } + return parsed +} + +function assertHeaderChecksum(header: Buffer): void { + const expected = tarOctal(header, TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_BYTES, 'checksum') + let actual = 0 + for (let index = 0; index < header.length; index += 1) { + actual += + index >= TAR_CHECKSUM_OFFSET && index < TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_BYTES + ? 0x20 + : header[index] + } + if (actual !== expected) { + throw new Error('SSH relay TAR header checksum mismatch') + } +} + +function headerPath(header: Buffer): string { + const name = tarString(header, 0, 100, 'name') + const prefix = tarString(header, 345, 155, 'prefix') + if (!name) { + throw new Error('SSH relay TAR entry name is empty') + } + const path = prefix ? `${prefix}/${name}` : name + return path.endsWith('/') ? path.slice(0, -1) : path +} + +function assertHeaderFormat(header: Buffer): void { + if ( + !header.subarray(257, 263).equals(Buffer.from('ustar\0', 'ascii')) || + !header.subarray(263, 265).equals(Buffer.from('00', 'ascii')) + ) { + throw new Error('SSH relay TAR entry is not canonical USTAR') + } +} + +type ParsedHeader = { + declared: DeclaredEntry + path: string + size: number +} + +function parseHeader( + header: Buffer, + expected: ReadonlyMap, + seen: Set +): ParsedHeader { + assertHeaderChecksum(header) + assertHeaderFormat(header) + const path = headerPath(header) + const declared = expected.get(path) + if (!declared || seen.has(path)) { + throw new Error(`SSH relay TAR has an extra or duplicate entry: ${path}`) + } + seen.add(path) + const typeFlag = header[156] + const actualType = + typeFlag === 0 || typeFlag === 0x30 ? 'file' : typeFlag === 0x35 ? 'directory' : null + const mode = tarOctal(header, 100, 8, 'mode') + const size = tarOctal(header, 124, 12, 'size') + if (actualType !== declared.type || (mode & 0o777) !== declared.mode) { + throw new Error(`SSH relay TAR type or mode mismatch: ${path}`) + } + if (declared.type === 'directory' ? size !== 0 : size !== declared.size) { + throw new Error(`SSH relay TAR size mismatch: ${path}`) + } + return { declared, path, size } +} + +function assertZeroPadding(bytes: Buffer, start: number, end: number): void { + if (!allZero(bytes, start, end)) { + throw new Error('SSH relay TAR file padding must be zero') + } +} + +function inspectionSink(tuple: SelectedTuple): Writable { + const expected = new Map(tuple.entries.map((entry) => [entry.path, entry])) + const expectedTarBytes = + tuple.entries.reduce( + (total, entry) => + total + + TAR_BLOCK_BYTES + + (entry.type === 'file' ? Math.ceil(entry.size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES : 0), + 0 + ) + + 2 * TAR_BLOCK_BYTES + const seen = new Set() + const header = Buffer.allocUnsafe(TAR_BLOCK_BYTES) + let headerBytes = 0 + let remainingFileBytes = 0 + let remainingPaddingBytes = 0 + let fileDigest: Hash | null = null + let fileEntry: Extract | null = null + let filePath = '' + let endBlocks = 0 + let inspectedBytes = 0 + + function finishFile(): void { + if (!fileDigest || !fileEntry) { + return + } + const actual = `sha256:${fileDigest.digest('hex')}` + if (actual !== fileEntry.sha256) { + throw new Error(`SSH relay TAR file integrity mismatch: ${filePath}`) + } + fileDigest = null + fileEntry = null + filePath = '' + } + + function acceptHeader(): void { + if (allZero(header)) { + endBlocks += 1 + return + } + if (endBlocks !== 0) { + throw new Error('SSH relay TAR end marker is interrupted by another entry') + } + const parsed = parseHeader(header, expected, seen) + remainingFileBytes = parsed.size + remainingPaddingBytes = (TAR_BLOCK_BYTES - (parsed.size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES + if (parsed.declared.type === 'file') { + fileDigest = createHash('sha256') + fileEntry = parsed.declared + filePath = parsed.path + if (parsed.size === 0) { + finishFile() + } + } + } + + function consume(chunk: Buffer): void { + let offset = 0 + while (offset < chunk.length) { + if (remainingFileBytes > 0) { + const length = Math.min(remainingFileBytes, chunk.length - offset) + inspectedBytes += length + fileDigest?.update(chunk.subarray(offset, offset + length)) + remainingFileBytes -= length + offset += length + if (remainingFileBytes === 0) { + finishFile() + } + continue + } + if (remainingPaddingBytes > 0) { + const length = Math.min(remainingPaddingBytes, chunk.length - offset) + inspectedBytes += length + assertZeroPadding(chunk, offset, offset + length) + remainingPaddingBytes -= length + offset += length + continue + } + const length = Math.min(TAR_BLOCK_BYTES - headerBytes, chunk.length - offset) + inspectedBytes += length + if (inspectedBytes > expectedTarBytes) { + throw new Error('SSH relay TAR exceeds its signed aggregate size') + } + chunk.copy(header, headerBytes, offset, offset + length) + headerBytes += length + offset += length + if (headerBytes === TAR_BLOCK_BYTES) { + headerBytes = 0 + acceptHeader() + } + } + } + + function finish(): void { + if ( + headerBytes !== 0 || + remainingFileBytes !== 0 || + remainingPaddingBytes !== 0 || + fileDigest || + endBlocks !== 2 || + inspectedBytes !== expectedTarBytes + ) { + throw new Error('SSH relay TAR is truncated or lacks its two-block end marker') + } + const missing = tuple.entries.find((entry) => !seen.has(entry.path)) + if (missing) { + throw new Error(`SSH relay TAR is missing a declared entry: ${missing.path}`) + } + } + + return new Writable({ + write(chunk: Buffer, _encoding, callback) { + try { + consume(chunk) + callback() + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))) + } + }, + final(callback) { + try { + finish() + callback() + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))) + } + } + }) +} + +export async function inspectSshRelayTarBrotli({ + archivePath, + tuple, + signal, + chunkBytes +}: { + archivePath: string + tuple: SelectedTuple + signal: AbortSignal + chunkBytes: number +}): Promise { + // Why: a block-state inspector avoids retaining expanded TAR data before the separate write pass. + await pipeline( + createReadStream(archivePath, { highWaterMark: chunkBytes }), + createBrotliDecompress({ chunkSize: chunkBytes }), + inspectionSink(tuple), + { signal } + ) +} diff --git a/src/main/ssh/ssh-relay-artifact-tree-verification.ts b/src/main/ssh/ssh-relay-artifact-tree-verification.ts index 9c8c84d24f4..8c7d002e91c 100644 --- a/src/main/ssh/ssh-relay-artifact-tree-verification.ts +++ b/src/main/ssh/ssh-relay-artifact-tree-verification.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' -import { createReadStream } from 'node:fs' -import { lstat, readdir } from 'node:fs/promises' +import { lstat, open, readdir } from 'node:fs/promises' import { join, relative, sep } from 'node:path' import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' @@ -18,12 +17,39 @@ async function hashFile( ): Promise { const digest = createHash('sha256') let size = 0 - for await (const chunk of createReadStream(path, { highWaterMark: chunkBytes, signal })) { - size += chunk.length - if (size > expectedSize) { - throw new Error('SSH relay extracted tree file exceeds its signed size') + const handle = await open(path, 'r') + try { + const before = await handle.stat({ bigint: true }) + if (!before.isFile() || before.size !== BigInt(expectedSize)) { + throw new Error('SSH relay extracted tree file changed before hashing') } - digest.update(chunk) + const buffer = Buffer.allocUnsafe(Math.min(chunkBytes, Math.max(expectedSize, 1))) + while (size < expectedSize) { + signal.throwIfAborted() + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, expectedSize - size), + size + ) + if (bytesRead === 0) { + break + } + size += bytesRead + digest.update(buffer.subarray(0, bytesRead)) + } + const after = await handle.stat({ bigint: true }) + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error('SSH relay extracted tree file changed while hashing') + } + } finally { + await handle.close() } if (size !== expectedSize) { throw new Error('SSH relay extracted tree file size disagrees with its signed size')