From 7580600e3753e355f9a3876a40e58d23ba03bc9a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 15 Jul 2026 17:51:26 -0400 Subject: [PATCH] feat(ssh): add verified relay artifact extraction Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 39 ++ .../ssh-relay-runtime-workflow.test.mjs | 22 +- ...elease-implementation-checklist-summary.md | 27 +- ...github-release-implementation-checklist.md | 240 ++++++++- ...elay-artifact-extraction-full-size.test.ts | 114 ++++ .../ssh/ssh-relay-artifact-extraction.test.ts | 503 ++++++++++++++++++ src/main/ssh/ssh-relay-artifact-extraction.ts | 177 ++++++ .../ssh/ssh-relay-artifact-tar-extraction.ts | 130 +++++ .../ssh-relay-artifact-tree-verification.ts | 103 ++++ .../ssh/ssh-relay-artifact-zip-extraction.ts | 103 ++++ src/main/ssh/ssh-relay-artifact-zip-reader.ts | 285 ++++++++++ 11 files changed, 1724 insertions(+), 19 deletions(-) create mode 100644 src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts create mode 100644 src/main/ssh/ssh-relay-artifact-extraction.test.ts create mode 100644 src/main/ssh/ssh-relay-artifact-extraction.ts create mode 100644 src/main/ssh/ssh-relay-artifact-tar-extraction.ts create mode 100644 src/main/ssh/ssh-relay-artifact-tree-verification.ts create mode 100644 src/main/ssh/ssh-relay-artifact-zip-extraction.ts create mode 100644 src/main/ssh/ssh-relay-artifact-zip-reader.ts diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 3312e25d890..1378fb72afb 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -263,6 +263,7 @@ jobs: src/main/ssh/ssh-relay-release-asset.test.ts \ src/main/ssh/ssh-relay-artifact-selector.test.ts \ src/main/ssh/ssh-relay-artifact-download.test.ts \ + src/main/ssh/ssh-relay-artifact-extraction.test.ts \ src/main/ssh/ssh-relay-runtime-identity.test.ts \ config/scripts/ssh-relay-runtime-compatibility.test.mjs \ config/scripts/ssh-relay-node-release-verification.test.mjs \ @@ -501,6 +502,23 @@ jobs: cp "$first_output"/*.tar.br "$first_output"/*.spdx.json "$first_output"/*.provenance.json "$first_output"/*.identity.json \ "$signing_report" "$GITHUB_WORKSPACE/runtime-evidence/${{ matrix.tuple }}/" + - name: Measure full-size desktop extraction boundary + shell: bash + run: | + set -euo pipefail + first_output="$RUNNER_TEMP/ssh-relay-runtime/${{ matrix.tuple }}/first" + identity="$first_output/orca-ssh-relay-runtime-${{ matrix.tuple }}.identity.json" + archive=$(find "$first_output" -maxdepth 1 -name '*.tar.br' -print -quit) + test -n "$archive" + extraction_output="$RUNNER_TEMP/ssh-relay-runtime/${{ matrix.tuple }}/desktop-extraction-measurement" + # Why: exact payload size can expose memory behavior that synthetic archive fixtures cannot. + ORCA_SSH_RELAY_FULL_SIZE_ARCHIVE="$archive" \ + ORCA_SSH_RELAY_FULL_SIZE_IDENTITY="$identity" \ + ORCA_SSH_RELAY_FULL_SIZE_OUTPUT="$extraction_output" \ + pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 --reporter=verbose \ + src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts + test ! -e "$extraction_output" + - name: Upload unpublished artifact evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -769,6 +787,7 @@ jobs: src/main/ssh/ssh-relay-release-asset.test.ts ` src/main/ssh/ssh-relay-artifact-selector.test.ts ` src/main/ssh/ssh-relay-artifact-download.test.ts ` + src/main/ssh/ssh-relay-artifact-extraction.test.ts ` src/main/ssh/ssh-relay-runtime-identity.test.ts ` config/scripts/ssh-relay-runtime-compatibility.test.mjs ` config/scripts/ssh-relay-node-release-verification.test.mjs ` @@ -1010,6 +1029,26 @@ jobs: Copy-Item -LiteralPath $signingReport -Destination $evidence Copy-Item -LiteralPath $sourceSignatureReport -Destination $evidence + - name: Measure full-size desktop extraction boundary + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $firstOutput = Join-Path $env:RUNNER_TEMP 'ssh-relay-runtime/${{ matrix.tuple }}/first' + $identity = Join-Path $firstOutput 'orca-ssh-relay-runtime-${{ matrix.tuple }}.identity.json' + $archive = Get-ChildItem -LiteralPath $firstOutput -Filter '*.zip' -File | Select-Object -First 1 + if (-not $archive) { throw 'runtime ZIP is missing for desktop extraction measurement' } + $extractionOutput = Join-Path $env:RUNNER_TEMP 'ssh-relay-runtime/${{ matrix.tuple }}/desktop-extraction-measurement' + # Why: exact payload size can expose memory behavior that synthetic archive fixtures cannot. + $env:ORCA_SSH_RELAY_FULL_SIZE_ARCHIVE = $archive.FullName + $env:ORCA_SSH_RELAY_FULL_SIZE_IDENTITY = $identity + $env:ORCA_SSH_RELAY_FULL_SIZE_OUTPUT = $extractionOutput + pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 --reporter=verbose ` + src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts + if ($LASTEXITCODE -ne 0) { throw 'full-size desktop extraction measurement failed' } + if (Test-Path -LiteralPath $extractionOutput) { + throw 'full-size desktop extraction measurement cleanup failed' + } + - name: Upload unpublished artifact evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 1a2a6de185d..f43c628604e 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -94,9 +94,25 @@ describe('SSH relay runtime artifact workflow', () => { const uploadIndex = steps.findIndex( (step) => step.name === 'Upload unpublished artifact evidence' ) + const measurementIndex = steps.findIndex( + (step) => step.name === 'Measure full-size desktop extraction boundary' + ) + const windowsBuildIndex = windowsSteps.findIndex( + (step) => step.name === 'Build twice, inspect, smoke, and compare exact runtime' + ) + const windowsMeasurementIndex = windowsSteps.findIndex( + (step) => step.name === 'Measure full-size desktop extraction boundary' + ) + const windowsUploadIndex = windowsSteps.findIndex( + (step) => step.name === 'Upload unpublished artifact evidence' + ) expect(buildIndex).toBeGreaterThan(-1) - expect(uploadIndex).toBeGreaterThan(buildIndex) + expect(windowsBuildIndex).toBeGreaterThan(-1) + expect(measurementIndex).toBeGreaterThan(buildIndex) + expect(uploadIndex).toBeGreaterThan(measurementIndex) + expect(windowsMeasurementIndex).toBeGreaterThan(windowsBuildIndex) + expect(windowsUploadIndex).toBeGreaterThan(windowsMeasurementIndex) expect(source).toContain('verify-ssh-relay-runtime.mjs') expect(source).toContain('ssh-relay-runtime-workflow.test.mjs') for (const testName of [ @@ -105,12 +121,16 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-release-asset.test.ts', 'ssh-relay-artifact-selector.test.ts', 'ssh-relay-artifact-download.test.ts', + 'ssh-relay-artifact-extraction.test.ts', 'ssh-relay-runtime-identity.test.ts' ]) { // Why: portable desktop selection contracts need proof on every native runner family, not // only the local client architecture or the generic Linux PR job. expect(source.split(`src/main/ssh/${testName}`)).toHaveLength(3) } + expect( + source.split('src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts') + ).toHaveLength(3) expect(source).toContain('pnpm install --frozen-lockfile --ignore-scripts') expect(source).toContain('--connect-timeout 20 --max-time 300 --retry 2') expect(source).toContain('mkdir -p "$output_root"') 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 7176802a3f2..95294f2efce 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,16 +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 5 / Work Package 4 desktop cache boundary — in-progress disconnected -extraction/cache contract audit, 2026-07-15, Codex implementation owner.** Exact-head run +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 [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 Windows arm64 passes smoke before the retained hosted build-26200 versus required-26100 rejection. -The next safe slice is only the isolated desktop extraction/cache resource and failure-semantics -audit followed by a purpose-named RED. Desktop consumers, SSH transfer/install, mode wiring, -tuple enablement, publication, production keys/environment/seed, and merge to `main` remain -disconnected. +The isolated desktop extractor is locally green under `E-M5-ARTIFACT-EXTRACTION-LOCAL-001` and +`E-M5-ARTIFACT-EXTRACTION-BROAD-LOCAL-001`: 17 focused tests cover both archive families, hostile +inputs, exact tree/modes, mutation, cancellation, cleanup, and ownership; broad relay/release/static +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. ## Safety status @@ -307,9 +312,15 @@ disconnected. - [ ] Stream bounded downloads; verify signature, size, archive hash, and extracted tree. The disconnected Electron downloader is locally green under `E-M5-ARTIFACT-DOWNLOAD-LOCAL-RED-001` and `E-M5-ARTIFACT-DOWNLOAD-LOCAL-001`; exact-head - all-six native CI is closed under `E-M5-ARTIFACT-DOWNLOAD-CI-001`. Packaged signature loading - and extracted-tree verification remain open. + all-six native CI is closed under `E-M5-ARTIFACT-DOWNLOAD-CI-001`. Disconnected strict + TAR/Brotli and ZIP extraction plus complete-tree verification are locally green under + `E-M5-ARTIFACT-EXTRACTION-LOCAL-001`; an exact full-size Actions payload is locally within time + and memory budgets under `E-M5-ARTIFACT-EXTRACTION-FULL-SIZE-LOCAL-001`. Native all-six + execution and packaged signature loading remain open. - [ ] Add exclusive staging, atomic publication, quarantine, locking, and the 2 GiB cache policy. + Exclusive owned staging and failure/cancellation cleanup are locally green under + `E-M5-ARTIFACT-EXTRACTION-LOCAL-001`; publication, quarantine, locking, and eviction are not + implemented yet. - [ ] Prove verified cached bytes can be transferred while the client is offline. - [ ] Preserve `ORCA_RELAY_PATH` behind the official-build trust boundary. 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 50e14f97e3c..9a94d3a3a4a 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/cache contract audit and RED**. Exact-head run 29449869519 closes the portable POSIX artifact prerequisite under E-M5-PORTABLE-ARCHIVE-CI-001: all six primary Node 24 jobs and both Linux oldest-userland supplements pass deterministic build, strict inspection/extraction, bundled Node/PTY/watcher smoke, equality, and read-back. Audit the existing desktop boundaries and resource/failure contracts, then add only a purpose-named missing extraction/cache-staging RED. Do not add a desktop caller, cache publication, SSH transfer/install, tuple enablement, mode wiring, or default behavior.
-Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — exact implementation/evidence head `3080eda77fd6a11ed87f536c1963e5b67abe7621` passes artifact run 29449869519 on all six primary native jobs and both Linux supplements. PR Checks 29449869451 and both Golden E2E jobs in run 29449869407 are green. Windows x64 oldest-floor execution passes; Windows arm64 reconstructs and smokes the exact runtime in 7,884.492ms with 49,733,632-byte RSS, then rejects hosted build 26200 against required build 26100. E-M5-PORTABLE-ARCHIVE-CI-001 records exact jobs, runner images, tests, artifact IDs/sizes/hashes, and smoke metrics. 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, desktop consumer, cache publication/extraction, 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: 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. Checkpoint the exact implementation SHA, push it, 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.
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) @@ -138,10 +138,10 @@ same change as the work it records. archive, correction, all-six primary jobs, and both Linux oldest-userland supplements. Windows ZIP and upstream Node `.tar.xz` remain unchanged. - Active package: **In progress — 2026-07-15, Codex implementation owner** — disconnected desktop - extraction/cache contract audit and purpose-named RED. Inventory existing cache/path/atomic-file - boundaries, formalize resource and integrity failure oracles, and prove the missing contract before - implementation. Keep cache publication, desktop call sites, SSH behavior, mode wiring, tuple - enablement, release writes, and defaults intact. + extraction capability after E-M5-ARTIFACT-EXTRACTION-AUDIT-001 and + E-M5-ARTIFACT-EXTRACTION-LOCAL-RED-001. Implement only strict TAR/Brotli and ZIP staging plus full + signed-tree verification and bounded cleanup. Keep cache publication/eviction, desktop call sites, + SSH behavior, mode wiring, tuple enablement, release writes, and defaults intact. - Completed Work Package 2 gate: target-native Windows source-signature reports from exact-head artifact jobs 87267322867 and 87267322870 were independently downloaded and matched to their identities and signing-stage reports under E-M3-WINDOWS-SOURCE-SIGNATURE-CI-001. PR Checks @@ -1084,8 +1084,11 @@ Suggested concrete modules: - [x] Verify archive SHA-256 before extraction. E-M5-ARTIFACT-DOWNLOAD-LOCAL-001 and E-M5-ARTIFACT-DOWNLOAD-CI-001. - [ ] Extract into an exclusive temporary directory with all archive-safety checks from Milestone 2. - Begin only after the `.tar.br` prerequisite has exact-head all-six artifact evidence. + Implemented locally under E-M5-ARTIFACT-EXTRACTION-LOCAL-001 after the `.tar.br` prerequisite; + native cross-client and exact full-size evidence remain required before this item closes. - [ ] Verify the exact expanded tree before atomically publishing the cache entry. + The disconnected full-tree verifier passes locally under E-M5-ARTIFACT-EXTRACTION-LOCAL-001; + atomic cache publication is deliberately absent and remains a later package. - [ ] Coordinate cache ownership across multiple Orca processes and windows. - [ ] Recover stale download/extraction locks without deleting an active writer. - [ ] Never select partial files, partial directories, or entries with a failed verification record. @@ -12547,6 +12550,222 @@ config/scripts/ssh-relay-runtime-portable-archive.test.mjs`; changed-file `oxfmt - Follow-up: checkpoint this evidence and begin only the disconnected desktop extraction/cache boundary audit and purpose-named RED. +### E-M5-ARTIFACT-EXTRACTION-AUDIT-001 — Desktop extraction boundary and package split + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: exact local/pushed head `a93e67413a2a1446e8e0f7348af1fdf72737287d` after portable archive + evidence; draft PR #8741. +- Exact audit commands: `rg --files src/main/ssh config/scripts | rg 'ssh-relay|relay-runtime' | +sort`; targeted `rg -n` over `cache|extract|atomic|quarantine`; and complete reads of the desktop + schema/consistency/path-policy/selector/downloader plus release TAR/Brotli, ZIP, extraction, and + full-tree verification modules. +- Findings: + - the desktop has a signature-verified immutable selected-artifact value and an exclusive, + streaming size/hash-verified downloader, but no extraction or cache module and no consumer; + - release-job extractors under `config/scripts` are not packaged desktop modules and use the + five-minute release boundary rather than the two-minute desktop extraction budget; + - the verified selected artifact already carries the exact content ID, archive name/size/hash, + entry paths/types/modes/sizes/hashes, file count, expanded size, and portable native-attestation + hashes required for cross-client verification; + - extraction must revalidate the regular archive file and its signed size/hash before staging, + strictly inspect the authenticated archive family, extract only into an exclusive leaf, stream a + complete no-extra-file tree verification, detect source mutation, and remove only staging it + created on every failure/cancellation; + - native execution/trust probes are deliberately not part of desktop cross-client extraction. + Their signed hashes are bound by tuple consistency; target-native execution remains a later + remote-install gate. +- Package split: `ssh-relay-artifact-extraction.ts` owns input state, timeout, exclusive staging, and + cleanup; `ssh-relay-artifact-tar-extraction.ts` owns strict bounded TAR/Brotli behavior; + `ssh-relay-artifact-zip-extraction.ts` owns strict bounded ZIP behavior; and + `ssh-relay-artifact-tree-verification.ts` owns streamed complete-tree verification. Cache locking, + publication records, quarantine, eviction, and lookup stay in the later + `ssh-relay-artifact-cache.ts` package. +- Budgets: two-minute extraction timeout; 65,536-byte stream chunks; 64 MiB maximum measured desktop + incremental RSS; no buffer over 1 MiB; archive/expanded/file/count/path limits remain signed-schema + constraints. This package must expose the constants for contract and measurement tests. +- Consumer-disconnection oracle: this audit and the next capability do not create a cache root, + publish an entry, load an embedded key/manifest, call the downloader, transfer over SSH, enable a + tuple/mode, or change the legacy default. +- Does not prove: implementation, hostile archive behavior, packaged Electron behavior, full-size + memory/latency, cache concurrency/publication/quarantine/eviction, live SSH, native trust, or any + enabled tuple. +- Follow-up: add the purpose-named cross-family RED, then implement only the disconnected extraction + capability across the concrete modules above. + +### E-M5-ARTIFACT-EXTRACTION-LOCAL-RED-001 — Packaged desktop extraction capability is absent + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: uncommitted purpose-named + `src/main/ssh/ssh-relay-artifact-extraction.test.ts` atop audit head `a93e67413`. +- Exact command: + `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-artifact-extraction.test.ts` +- Environment: macOS 26.2 arm64, Node v26.0.0, pnpm 10.24.0, Vitest 4.1.5. +- Result: required RED; one failed suite / zero collected tests in 1.75s Vitest / 7.67s wall with + 132,104,192-byte maximum RSS and zero swaps. Import fails because + `./ssh-relay-artifact-extraction` does not exist. +- Contract encoded before implementation: + - synthesize real TAR/Brotli and ZIP archives on every client OS; + - expose exact 120,000ms / 65,536-byte / 64 MiB incremental-RSS / 1 MiB maximum-buffer budgets; + - revalidate signed archive size/hash, strictly reject mismatched and undeclared entries, verify + every extracted file byte, and accept no extra tree entry; + - create a fresh parent but an exclusive final staging leaf; settle pre-cancellation; remove only + owned partial output and preserve an existing owner. +- Does not prove: implementation, collected test behavior, Node 24/native clients, full-size memory, + packaged Electron extraction, cache publication/concurrency, SSH, or any enabled tuple. +- Follow-up: implement the smallest disconnected extraction capability without cache or consumer + wiring, then run focused/release/desktop/static gates and require native CI. + +### E-M5-ARTIFACT-EXTRACTION-LOCAL-001 — Disconnected desktop extraction passes locally + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: uncommitted extraction capability and purpose suite atop pushed head + `a93e67413a2a1446e8e0f7348af1fdf72737287d`; the exact implementation SHA will be appended after + the local broad gates pass and this evidence is checkpointed. +- Environment: macOS 26.2 arm64, Node v26.0.0, pnpm 10.24.0, Vitest 4.1.5. Node 24/native client + execution remains the next gate. +- Exact focused command: + `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-artifact-extraction.test.ts` +- Focused result: one passed file / 17 passed tests in 1.65s Vitest / 4.15s wall; 158,105,600-byte + maximum RSS, 96,179,552-byte peak memory footprint, zero swaps, and 353 page faults. The observed + process maximum is 26,001,408 bytes above the 132,104,192-byte missing-module RED process baseline, + below the 64 MiB incremental contract. This is a synthetic 8 MiB cancellation/mutation fixture, + not the separately required exact full-size artifact measurement. +- Exact static commands: + `pnpm exec oxlint src/main/ssh/ssh-relay-artifact-extraction.ts src/main/ssh/ssh-relay-artifact-tar-extraction.ts src/main/ssh/ssh-relay-artifact-tree-verification.ts src/main/ssh/ssh-relay-artifact-zip-reader.ts src/main/ssh/ssh-relay-artifact-zip-extraction.ts src/main/ssh/ssh-relay-artifact-extraction.test.ts`; + `pnpm typecheck`. +- Static result: both commands exit zero. The implementation is split by concrete responsibility; + no max-lines disable or vague helper/common module was added. +- Oracle proved: + - only the immutable selected tuple/archive identity crosses the boundary; the exact signed regular + archive size/hash and filesystem state are checked before staging and again after extraction; + - TAR/Brotli and ZIP are strictly inspected and streamed on every client OS into an exclusive leaf + beneath a resolved parent; POSIX modes and every signed path/type/size/hash are revalidated; + - extra/traversal/truncated/case-fold-colliding entries, ZIP symbolic links/encryption shapes, + wrong bytes, archive mutation, and existing-owner collisions fail closed; + - pre-cancellation and cancellation after staging begins settle, remove only owned partial output, + and leave no eligible partial runtime tree; + - extraction retains the declared 120,000ms timeout, 65,536-byte chunks, 64 MiB measured + incremental-RSS budget, and 1 MiB maximum-write-buffer contract. +- Consumer-disconnection oracle: there is no cache root or publication record, downloader/manifest + loader caller, SSH transfer/install, runtime execution, target setting, Beta option, tuple + enablement, release write, or artifact publication. Legacy remains the only production/default + path. +- Does not prove: Node 24 or native Windows/Linux/macOS clients, packaged Electron behavior, exact + full-size runtime memory/latency, cache locking/publication/quarantine/eviction, live SSH, native + trust, or any enabled tuple. +- Follow-up: run the broad relay/release/static gates, prove the focused source suite is invoked by + both POSIX and Windows native artifact job families, then collect all-six Node 24 and exact + full-size payload evidence before beginning cache publication. + +### E-M5-ARTIFACT-EXTRACTION-BROAD-RED-001 — Broad run exposes late rejection observer + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: uncommitted E-M5-ARTIFACT-EXTRACTION-LOCAL-001 tree atop `a93e67413`. +- Exact command: + `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-artifact-*.test.ts` +- Result: required RED; three passed files / one failed file, 80 passed tests / one failed test, and + one unhandled rejection in 2.76s. The authenticated mutation test correctly fails closed with + `unexpected EOF`, but can reject during its awaited `writeFile` before the later Vitest assertion + attaches, so the runner reports `PromiseRejectionHandledWarning` and an unhandled rejection. +- Classification: purpose-test timing defect, not a product extraction acceptance. The owned staging + tree is still removed and the mutation is rejected. +- Correction: attach the Vitest rejection observer when extraction starts, before waiting for the + staging path or mutating/aborting; accept the valid fail-closed `unexpected EOF` diagnostic. +- Follow-up: rerun the exact broad artifact command and require zero unhandled errors before any CI + checkpoint. + +### E-M5-ARTIFACT-EXTRACTION-BROAD-LOCAL-001 — Broad local relay and release gates pass + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: corrected local extraction tree atop `a93e67413`; exact implementation SHA pending the + checkpoint commit. +- Exact commands and results: + - `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-artifact-*.test.ts`: + four passed files / 81 passed tests in 10.40s after the mutation observer correction, with zero + unhandled errors; + - `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-*.test.ts`: + 17 passed files / one platform-skipped file, 249 passed tests / one platform-skipped test in + 21.60s Vitest / 24.97s wall, 244,383,744-byte maximum process RSS and zero swaps; + - `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-*.test.mjs`: + 50 passed files / 279 passed tests in 45.04s Vitest / 47.58s wall, 190,840,832-byte maximum + process RSS and zero swaps; + - `pnpm typecheck`; `pnpm lint`; `git diff --check`: all exit zero. Full lint retains only existing + warnings outside this package; reliability gates, max-lines ratchet, bundled skills, localization + catalog, and localization coverage pass. +- Diff oracle: no diff exists in the preserved Milestone 0 resolver pair or + `config/scripts/verify-release-required-assets.mjs` and its test. Only the concrete extraction, + measurement, workflow-wiring, and living-plan files are in scope. +- Does not prove: Node 24/native clients, exact full-size payload behavior, cache, live SSH, native + trust, or any enabled tuple. +- Follow-up: measure an exact downloaded payload, close native workflow wiring, then checkpoint and + push the implementation for all-six execution. + +### E-M5-ARTIFACT-EXTRACTION-FULL-SIZE-LOCAL-001 — Exact downloaded payload stays within budget + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source artifact: Actions artifact ID 8357083684, `ssh-relay-runtime-darwin-arm64`, downloaded from + exact implementation run [29449869519](https://github.com/stablyai/orca/actions/runs/29449869519) + using `gh run download 29449869519 --repo stablyai/orca --name ssh-relay-runtime-darwin-arm64`. +- Payload: `darwin-arm64`, archive SHA-256 + `2031bea0f820107f8c92ab62a446fe0ebbeb64e78b63b6707c0610216ac68e3d`, 31,776,556 compressed + bytes, 122,027,869 expanded bytes, and 35 files. +- Exact measurement command: set `ORCA_SSH_RELAY_FULL_SIZE_ARCHIVE`, + `ORCA_SSH_RELAY_FULL_SIZE_IDENTITY`, and an absent owned `ORCA_SSH_RELAY_FULL_SIZE_OUTPUT`, then + run `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 --reporter=verbose src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts`. +- Result: four consecutive post-harness runs pass locally. The retained verbose run extracts, + complete-tree verifies, and cleans the exact payload in 878.731667ms with a 72,679,424-byte + baseline RSS, 121,962,496-byte sampled peak RSS, and 49,283,072-byte incremental RSS. This is below + the 120,000ms and 67,108,864-byte limits. +- Harness boundary: the measurement uses the authenticated Actions identity only to construct the + top-level selected-artifact shape; its explicit why-comment prohibits product use. The production + extractor still rehashes the archive, inspects it, creates exclusive staging, verifies the complete + tree, rehashes the archive, and cleans output. Signature selection itself is separately proven by + E-M5-OFFLINE-SELECTION-LOCAL-001/CI-001. +- Does not prove: Node 24, other native clients or archive families at full size, packaged Electron, + cache, SSH, or an enabled tuple. +- Follow-up: require this measurement after native build and before upload on all six artifact jobs; + retain synthetic both-family execution on every client runner. + +### E-M5-ARTIFACT-EXTRACTION-CI-WIRING-RED-001 — Native jobs omit extraction proof + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Exact command: + `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-workflow.test.mjs` +- Result: required RED; one failed / six passed tests in 483ms. The new source contract expects the + purpose suite in both native job families, but the workflow contains zero occurrences, so the + split count is one rather than three. +- Follow-up: invoke the synthetic cross-family suite in both native contract steps and add a + post-build full-size budget measurement before artifact upload in both job families. + +### E-M5-ARTIFACT-EXTRACTION-CI-WIRING-LOCAL-001 — Both native job families require extraction gates + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: local workflow/test/full-size harness atop `a93e67413`; exact implementation SHA pending + the checkpoint commit. +- Exact command: + `pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 config/scripts/ssh-relay-runtime-workflow.test.mjs` +- Result: one passed file / seven passed tests in 404ms after formatting; typecheck also exits zero. +- Oracle proved: all four POSIX native clients and both Windows native clients run the 17-test + synthetic TAR/Brotli-plus-ZIP suite under Node 24. After each exact native build, a separate step + runs the one-test full-size measurement against that job's first verified archive/identity, checks + 120,000ms / 64 MiB, cleans its owned output, and must pass before upload. The workflow contract + requires both measurement steps to occur after build and before upload. +- Consumer-disconnection oracle: workflow artifacts remain unpublished Actions evidence; no release + write, packaged desktop consumer, cache publication, SSH transfer/install, tuple, or mode is + connected. +- Does not prove: actual runner execution, cross-family full-size payloads on every client, packaged + Electron, cache, SSH, or any enabled tuple. +- 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. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit @@ -12605,9 +12824,10 @@ The project is not complete until every applicable item below is checked with ev ## Next Required Action -Audit the disconnected desktop extraction/cache boundary and add only the purpose-named missing -resource/failure-semantics RED. Do not implement a desktop call site, cache publication, SSH -transfer/install, mode wiring, tuple enablement, or default behavior in that audit/test package. Keep +Run the broader release/desktop/static gates for the locally green disconnected extraction +capability, wire its purpose suite into both native artifact job families, and collect exact-head +all-six Node 24 plus full-size payload evidence. Do not add a desktop call site, cache +publication/eviction, SSH transfer/install, mode wiring, tuple enablement, 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, diff --git a/src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts b/src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts new file mode 100644 index 00000000000..bd4f4e2c08a --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-extraction-full-size.test.ts @@ -0,0 +1,114 @@ +import { readFile, rm, stat } from 'node:fs/promises' +import { performance } from 'node:perf_hooks' + +import { describe, expect, it } from 'vitest' + +import { + extractSshRelayArtifact, + SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS +} from './ssh-relay-artifact-extraction' +import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' + +type MeasurementIdentity = { + tupleId: SshRelaySelectedArtifact['tupleId'] + contentId: SshRelaySelectedArtifact['contentId'] + os: SshRelaySelectedArtifact['tuple']['os'] + archive: SshRelaySelectedArtifact['tuple']['archive'] + entries: SshRelaySelectedArtifact['tuple']['entries'] +} + +const archivePath = process.env.ORCA_SSH_RELAY_FULL_SIZE_ARCHIVE +const identityPath = process.env.ORCA_SSH_RELAY_FULL_SIZE_IDENTITY +const outputDirectory = process.env.ORCA_SSH_RELAY_FULL_SIZE_OUTPUT +const hasMeasurementInput = Boolean(archivePath && identityPath && outputDirectory) + +function measurementIdentity(input: unknown): MeasurementIdentity { + if ( + typeof input !== 'object' || + input === null || + !('tupleId' in input) || + !('contentId' in input) || + !('os' in input) || + !('archive' in input) || + !('entries' in input) || + !Array.isArray(input.entries) + ) { + throw new Error('Full-size extraction measurement identity is incomplete') + } + return input as MeasurementIdentity +} + +function measurementArtifact(identity: MeasurementIdentity): SshRelaySelectedArtifact { + const tuple = identity as unknown as SshRelaySelectedArtifact['tuple'] + // Why: this runner measures exact Actions artifact resources; it is never a product trust bypass. + return Object.freeze({ + kind: 'selected', + tupleId: identity.tupleId, + contentId: identity.contentId, + releaseTag: 'measurement-only', + archive: Object.freeze({ + ...identity.archive, + downloadUrl: 'https://invalid.example/measurement-only' + }), + tuple + }) +} + +describe.skipIf(!hasMeasurementInput)('SSH relay full-size artifact extraction', () => { + it( + 'stays within the desktop time and incremental-memory budgets', + async () => { + const identity = measurementIdentity( + JSON.parse(await readFile(identityPath as string, 'utf8')) as unknown + ) + await expect(stat(outputDirectory as string)).rejects.toMatchObject({ code: 'ENOENT' }) + const baselineRss = process.memoryUsage().rss + let peakRss = baselineRss + const sample = (): void => { + peakRss = Math.max(peakRss, process.memoryUsage().rss) + } + const sampler = setInterval(sample, 1) + const startedAt = performance.now() + let result + try { + result = await extractSshRelayArtifact({ + artifact: measurementArtifact(identity), + archivePath: archivePath as string, + outputDirectory: outputDirectory as string + }) + } finally { + clearInterval(sampler) + sample() + } + const elapsedMs = performance.now() - startedAt + const incrementalRssBytes = Math.max(0, peakRss - baselineRss) + console.log( + `ssh_relay_full_size_extraction=${JSON.stringify({ + tupleId: identity.tupleId, + archiveBytes: identity.archive.size, + expandedBytes: identity.archive.expandedSize, + files: identity.archive.fileCount, + elapsedMs, + baselineRss, + peakRss, + incrementalRssBytes + })}` + ) + try { + expect(result).toMatchObject({ + tupleId: identity.tupleId, + contentId: identity.contentId, + files: identity.archive.fileCount, + expandedBytes: identity.archive.expandedSize + }) + expect(elapsedMs).toBeLessThanOrEqual(SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS.timeoutMs) + expect(incrementalRssBytes).toBeLessThanOrEqual( + SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS.maximumIncrementalMemoryBytes + ) + } finally { + await rm(outputDirectory as string, { recursive: true }) + } + }, + SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS.timeoutMs + 10_000 + ) +}) diff --git a/src/main/ssh/ssh-relay-artifact-extraction.test.ts b/src/main/ssh/ssh-relay-artifact-extraction.test.ts new file mode 100644 index 00000000000..44d1519a918 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-extraction.test.ts @@ -0,0 +1,503 @@ +import { createHash } from 'node:crypto' +import { lstat, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { brotliCompressSync, constants as zlibConstants } from 'node:zlib' + +import nacl from 'tweetnacl' +import { afterEach, describe, expect, it } from 'vitest' +import yazl from 'yazl' + +import { + extractSshRelayArtifact, + SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS +} from './ssh-relay-artifact-extraction' +import type { SshRelayArtifactManifest, SshRelayRuntimeTuple } from './ssh-relay-artifact-schema' +import { + createSshRelayArtifactTestManifest, + createSshRelayWindowsArtifactTestManifest +} from './ssh-relay-artifact-test-manifest' +import { + selectSshRelayArtifact, + type SshRelayHostEvidence, + type SshRelaySelectedArtifact +} from './ssh-relay-artifact-selector' +import { + signSshRelayArtifactManifest, + sshRelayManifestKeyId, + verifySshRelayArtifactManifest +} from './ssh-relay-manifest-signature' +import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset' +import { computeSshRelayRuntimeContentId, type SshRelayDigest } from './ssh-relay-runtime-identity' + +const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) +const temporaryDirectories: string[] = [] + +function sha256(bytes: Uint8Array): SshRelayDigest { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}` +} + +function tarString(header: Buffer, value: string, offset: number, length: number): void { + header.write(value, offset, length, 'utf8') +} + +function tarOctal(header: Buffer, value: number, offset: number, length: number): void { + header.write(value.toString(8).padStart(length - 1, '0'), offset, length - 1, 'ascii') + header[offset + length - 1] = 0 +} + +function tarHeader(entry: SshRelayRuntimeTuple['entries'][number], size: number): Buffer { + const header = Buffer.alloc(512) + tarString(header, entry.type === 'directory' ? `${entry.path}/` : entry.path, 0, 100) + tarOctal(header, entry.mode, 100, 8) + tarOctal(header, 0, 108, 8) + tarOctal(header, 0, 116, 8) + tarOctal(header, size, 124, 12) + tarOctal(header, 0, 136, 12) + header.fill(0x20, 148, 156) + header[156] = entry.type === 'directory' ? 0x35 : 0x30 + tarString(header, 'ustar\0', 257, 6) + tarString(header, '00', 263, 2) + const checksum = header.reduce((total, byte) => total + byte, 0) + header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii') + return header +} + +function tarBrotli( + tuple: SshRelayRuntimeTuple, + fileBytes: ReadonlyMap, + extraPath?: string +): Buffer { + const blocks: Buffer[] = [] + 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)) + if (bytes) { + blocks.push(bytes, Buffer.alloc((512 - (bytes.length % 512)) % 512)) + } + } + if (extraPath) { + const bytes = Buffer.from('undeclared archive bytes') + blocks.push( + tarHeader( + { + path: extraPath, + type: 'file', + role: 'license', + mode: 0o644, + size: 0, + sha256: sha256(bytes) + }, + bytes.length + ), + bytes, + Buffer.alloc((512 - (bytes.length % 512)) % 512) + ) + } + blocks.push(Buffer.alloc(1024)) + return brotliCompressSync(Buffer.concat(blocks), { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 9, + [zlibConstants.BROTLI_PARAM_LGWIN]: 20 + } + }) +} + +async function zipArchive( + tuple: SshRelayRuntimeTuple, + fileBytes: ReadonlyMap, + { + extraPath, + symlinkPath + }: { + extraPath?: string + symlinkPath?: string + } +): Promise { + const zip = new yazl.ZipFile() + const mtime = new Date('2026-07-14T00:00:00.000Z') + for (const entry of tuple.entries) { + const options = { mode: entry.mode, mtime, forceDosTimestamp: true } + if (entry.type === 'directory') { + zip.addEmptyDirectory(entry.path, options) + } else { + const bytes = fileBytes.get(entry.path) + if (!bytes) { + throw new Error(`Missing test bytes for ${entry.path}`) + } + zip.addBuffer(bytes, entry.path, { + ...options, + mode: entry.path === symlinkPath ? 0o120777 : entry.mode, + compress: true, + compressionLevel: 9 + }) + } + } + if (extraPath) { + zip.addBuffer(Buffer.from('undeclared archive bytes'), extraPath, { + mode: 0o644, + mtime, + forceDosTimestamp: true + }) + } + zip.end({ forceZip64Format: false }) + const chunks: Buffer[] = [] + for await (const chunk of zip.outputStream) { + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks) +} + +function markZipEntriesEncrypted(archive: Buffer): Buffer { + const changed = Buffer.from(archive) + for (let offset = 0; offset <= changed.length - 10; offset += 1) { + const signature = changed.readUInt32LE(offset) + const flagOffset = + signature === 0x04034b50 ? offset + 6 : signature === 0x02014b50 ? offset + 8 : -1 + if (flagOffset >= 0) { + changed.writeUInt16LE(changed.readUInt16LE(flagOffset) | 0x0001, flagOffset) + } + } + return changed +} + +type Fixture = { + archivePath: string + artifact: SshRelaySelectedArtifact + fileBytes: ReadonlyMap + root: string +} + +async function fixture({ + os, + mismatchedPath, + extraPath, + largeFileBytes, + truncated, + encryptedZip, + symlinkPath +}: { + os: 'linux' | 'win32' + mismatchedPath?: string + extraPath?: string + largeFileBytes?: number + truncated?: boolean + encryptedZip?: boolean + symlinkPath?: string +}): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-extraction-')) + temporaryDirectories.push(root) + const manifest: SshRelayArtifactManifest = + os === 'win32' + ? createSshRelayWindowsArtifactTestManifest() + : createSshRelayArtifactTestManifest() + const tuple = manifest.tuples[0] + const fileBytes = new Map() + const firstFilePath = tuple.entries.find((entry) => entry.type === 'file')?.path + for (const entry of tuple.entries) { + if (entry.type !== 'file') { + continue + } + const bytes = + entry.path === firstFilePath && largeFileBytes + ? Buffer.alloc(largeFileBytes, 0x61) + : Buffer.from(`desktop extraction fixture:${tuple.tupleId}:${entry.path}`) + fileBytes.set(entry.path, bytes) + entry.size = bytes.length + entry.sha256 = sha256(bytes) + } + for (const attestation of tuple.nativeVerification.files) { + const entry = tuple.entries.find((candidate) => candidate.path === attestation.path) + if (!entry || entry.type !== 'file') { + throw new Error(`Missing attested fixture entry: ${attestation.path}`) + } + attestation.sha256 = entry.sha256 + } + tuple.contentId = computeSshRelayRuntimeContentId(tuple) + tuple.archive.name = sshRelayRuntimeArchiveName(tuple.tupleId, tuple.contentId) + const files = tuple.entries.filter((entry) => entry.type === 'file') + tuple.archive.fileCount = files.length + tuple.archive.expandedSize = files.reduce((total, entry) => total + entry.size, 0) + + const archiveFileBytes = new Map(fileBytes) + if (mismatchedPath) { + archiveFileBytes.set(mismatchedPath, Buffer.from('authenticated archive has wrong tree bytes')) + } + let archive = + os === 'win32' + ? await zipArchive(tuple, archiveFileBytes, { extraPath, symlinkPath }) + : tarBrotli(tuple, archiveFileBytes, extraPath) + if (encryptedZip) { + archive = markZipEntriesEncrypted(archive) + } + if (truncated) { + archive = archive.subarray(0, Math.max(1, archive.length - 64)) + } + tuple.archive.size = archive.length + tuple.archive.sha256 = sha256(archive) + manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)] + const verified = verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + const host: SshRelayHostEvidence = + os === 'win32' + ? { + os, + architecture: 'x64', + processTranslated: false, + build: 22631, + openSshVersion: '9.5p1', + powerShellVersion: '5.1', + dotNetFrameworkRelease: 528040 + } + : { + os, + architecture: 'x64', + processTranslated: false, + kernelVersion: '6.8', + libc: { family: 'glibc', version: '2.39' }, + libstdcxxVersion: '6.0.33', + glibcxxVersion: '3.4.33' + } + const selected = selectSshRelayArtifact(verified, host) + if (selected.kind !== 'selected') { + throw new Error(`Expected selected fixture, got ${selected.reason}`) + } + const archivePath = join(root, tuple.archive.name) + await writeFile(archivePath, archive, { mode: 0o600 }) + return { archivePath, artifact: selected, fileBytes, root } +} + +async function waitForPath(path: string): Promise { + const deadline = Date.now() + 5_000 + for (;;) { + try { + await stat(path) + return + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for test path: ${path}`) + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('SSH relay artifact extraction', () => { + it('declares the desktop extraction resource contract', () => { + expect(SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS).toEqual({ + timeoutMs: 2 * 60_000, + chunkBytes: 64 * 1024, + maximumIncrementalMemoryBytes: 64 * 1024 * 1024, + maximumWriteBufferBytes: 1024 * 1024 + }) + }) + + it.each(['linux', 'win32'] as const)( + 'extracts and verifies an authenticated %s tree into an exclusive staging directory', + async (os) => { + const value = await fixture({ os }) + const outputDirectory = join(value.root, 'fresh-parent', 'runtime') + + const result = await extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + expect(result).toEqual({ + tupleId: value.artifact.tupleId, + contentId: value.artifact.contentId, + runtimeRoot: await realpath(outputDirectory), + files: value.artifact.tuple.archive.fileCount, + expandedBytes: value.artifact.tuple.archive.expandedSize + }) + for (const [path, bytes] of value.fileBytes) { + expect(await readFile(join(outputDirectory, ...path.split('/')))).toEqual(bytes) + } + if (process.platform !== 'win32') { + for (const entry of value.artifact.tuple.entries) { + const metadata = await lstat(join(outputDirectory, ...entry.path.split('/'))) + expect(metadata.mode & 0o777, entry.path).toBe(entry.mode) + } + } + } + ) + + it('rejects changed archive bytes before creating staging output', async () => { + const value = await fixture({ os: 'linux' }) + await writeFile(value.archivePath, 'changed after authenticated download') + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/size|sha-?256|archive/i) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.each(['linux', 'win32'] as const)( + 'rejects an authenticated %s archive whose extracted tree disagrees with the manifest', + async (os) => { + const value = await fixture({ os, mismatchedPath: 'relay.js' }) + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/integrity|sha-?256|size|tree/i) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it.each(['linux', 'win32'] as const)( + 'rejects an authenticated %s archive with an undeclared entry', + async (os) => { + const value = await fixture({ os, extraPath: 'unexpected.txt' }) + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/entry-count|extra|undeclared/i) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it('rejects an authenticated TAR traversal without writing outside owned staging', async () => { + const value = await fixture({ os: 'linux', extraPath: '../escaped.txt' }) + const outputDirectory = join(value.root, 'staging', 'runtime') + const escapedPath = join(value.root, 'staging', 'escaped.txt') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/extra|path|undeclared|traversal/i) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat(escapedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.each(['linux', 'win32'] as const)( + 'rejects an authenticated truncated %s archive and removes owned staging', + async (os) => { + const value = await fixture({ os, truncated: true }) + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/archive|brotli|end|invalid|tar|zip/i) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it.each([ + ['case-fold collision', { extraPath: 'RELAY.JS' }, /case-fold|duplicate/i], + ['symbolic link', { symlinkPath: 'relay.js' }, /symbolic link|type|mode/i], + // yauzl may reject the encrypted-size shape before exposing the flagged entry to our guard. + ['encrypted entry', { encryptedZip: true }, /encrypted|size mismatch/i] + ] as const)('rejects an authenticated ZIP %s', async (_name, options, expectedError) => { + const value = await fixture({ os: 'win32', ...options }) + const outputDirectory = join(value.root, 'staging', 'runtime') + + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(expectedError) + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('detects archive mutation after staging begins and removes its partial tree', async () => { + const value = await fixture({ os: 'win32', largeFileBytes: 8 * 1024 * 1024 }) + const outputDirectory = join(value.root, 'staging', 'runtime') + const rejection = expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory + }) + ).rejects.toThrow(/archive|central directory|changed|invalid|unexpected EOF|zip/i) + + await waitForPath(outputDirectory) + await writeFile(value.archivePath, Buffer.alloc(value.artifact.archive.size, 0x62)) + + await rejection + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }, 15_000) + + it('settles in-flight cancellation and removes only its partial staging tree', async () => { + const value = await fixture({ os: 'win32', largeFileBytes: 8 * 1024 * 1024 }) + const outputDirectory = join(value.root, 'staging', 'runtime') + const controller = new AbortController() + const rejection = expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory, + signal: controller.signal + }) + ).rejects.toThrow(/cancel|abort/i) + + await waitForPath(outputDirectory) + controller.abort(new Error('cancel in-flight desktop extraction')) + + await rejection + await expect(stat(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }) + }, 15_000) + + it('settles pre-cancellation and preserves an existing output owner', async () => { + const value = await fixture({ os: 'linux' }) + const controller = new AbortController() + controller.abort(new Error('cancel desktop extraction')) + const cancelledOutput = join(value.root, 'cancelled', 'runtime') + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory: cancelledOutput, + signal: controller.signal + }) + ).rejects.toThrow(/cancel desktop extraction/i) + await expect(stat(cancelledOutput)).rejects.toMatchObject({ code: 'ENOENT' }) + + const existingOutput = join(value.root, 'existing') + await writeFile(existingOutput, 'owner bytes') + await expect( + extractSshRelayArtifact({ + artifact: value.artifact, + archivePath: value.archivePath, + outputDirectory: existingOutput + }) + ).rejects.toMatchObject({ code: 'EEXIST' }) + expect(await readFile(existingOutput, 'utf8')).toBe('owner bytes') + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-extraction.ts b/src/main/ssh/ssh-relay-artifact-extraction.ts new file mode 100644 index 00000000000..8561d7c0d5f --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-extraction.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat, mkdir, 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 { 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_WRITE_BUFFER_BYTES = 1024 * 1024 + +type ArchiveState = { + dev: bigint + ino: bigint + size: bigint + mtimeNs: bigint + ctimeNs: bigint +} + +function sameArchiveState(left: ArchiveState, right: ArchiveState): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ) +} + +async function describeArchive( + archivePath: string, + expectedSize: number, + signal: AbortSignal +): Promise<{ sha256: string; state: ArchiveState }> { + signal.throwIfAborted() + const before = await lstat(archivePath, { bigint: true }) + if (!before.isFile() || before.isSymbolicLink() || before.size !== BigInt(expectedSize)) { + throw new Error('SSH relay extraction input must be the exact signed regular archive file') + } + 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') + } + digest.update(chunk) + } + const after = await lstat(archivePath, { bigint: true }) + if (!sameArchiveState(before, after) || size !== expectedSize) { + throw new Error('SSH relay extraction input changed while hashing') + } + return { sha256: `sha256:${digest.digest('hex')}`, state: after } +} + +function assertSelectedArtifact(artifact: SshRelaySelectedArtifact): void { + // Why: only the recursively frozen signature-verified selector result may cross this boundary. + if ( + artifact.tupleId !== artifact.tuple.tupleId || + artifact.contentId !== artifact.tuple.contentId || + artifact.archive.name !== artifact.tuple.archive.name || + artifact.archive.size !== artifact.tuple.archive.size || + artifact.archive.sha256 !== artifact.tuple.archive.sha256 + ) { + throw new Error('SSH relay selected artifact identity is inconsistent') + } +} + +export type SshRelayArtifactExtractionResult = { + tupleId: SshRelaySelectedArtifact['tupleId'] + contentId: SshRelaySelectedArtifact['contentId'] + runtimeRoot: string + files: number + expandedBytes: number +} + +export async function extractSshRelayArtifact({ + artifact, + archivePath, + outputDirectory, + signal +}: { + artifact: SshRelaySelectedArtifact + archivePath: string + outputDirectory: string + signal?: AbortSignal +}): Promise { + const effectiveSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(EXTRACTION_TIMEOUT_MS)]) + : AbortSignal.timeout(EXTRACTION_TIMEOUT_MS) + effectiveSignal.throwIfAborted() + assertSelectedArtifact(artifact) + const absoluteArchive = resolve(archivePath) + const absoluteOutput = resolve(outputDirectory) + const before = await describeArchive(absoluteArchive, artifact.archive.size, effectiveSignal) + if (before.sha256 !== artifact.archive.sha256) { + throw new Error('SSH relay extraction input SHA-256 disagrees with the signed manifest') + } + + const outputParent = dirname(absoluteOutput) + await mkdir(outputParent, { recursive: true, mode: 0o700 }) + const physicalParent = await realpath(outputParent) + const physicalOutput = resolve(physicalParent, basename(absoluteOutput)) + let outputCreated = false + try { + // Why: no cache publisher or concurrent extractor may observe a shared partial runtime tree. + await mkdir(physicalOutput, { mode: 0o700 }) + outputCreated = true + if (artifact.tuple.os === 'win32') { + await inspectSshRelayZip({ + archivePath: absoluteArchive, + tuple: artifact.tuple, + signal: effectiveSignal + }) + await extractSshRelayZip({ + archivePath: absoluteArchive, + outputDirectory: physicalOutput, + tuple: artifact.tuple, + signal: effectiveSignal + }) + } else { + await inspectSshRelayTarBrotli({ + archivePath: absoluteArchive, + tuple: artifact.tuple, + signal: effectiveSignal, + chunkBytes: CHUNK_BYTES + }) + await extractSshRelayTarBrotli({ + archivePath: absoluteArchive, + outputDirectory: physicalOutput, + tuple: artifact.tuple, + signal: effectiveSignal, + chunkBytes: CHUNK_BYTES + }) + } + const tree = await verifySshRelayArtifactTree({ + runtimeRoot: physicalOutput, + tuple: artifact.tuple, + signal: effectiveSignal, + chunkBytes: CHUNK_BYTES + }) + const after = await describeArchive(absoluteArchive, artifact.archive.size, effectiveSignal) + if (!sameArchiveState(before.state, after.state) || before.sha256 !== after.sha256) { + throw new Error('SSH relay extraction input changed during extraction') + } + effectiveSignal.throwIfAborted() + return { + tupleId: artifact.tupleId, + contentId: artifact.contentId, + runtimeRoot: physicalOutput, + ...tree + } + } catch (error) { + if (outputCreated) { + // Why: only a fully verified tree may remain eligible for later atomic cache publication. + await rm(physicalOutput, { recursive: true, force: true }) + } + throw error + } +} + +export const SSH_RELAY_ARTIFACT_EXTRACTION_LIMITS = Object.freeze({ + timeoutMs: EXTRACTION_TIMEOUT_MS, + chunkBytes: CHUNK_BYTES, + maximumIncrementalMemoryBytes: MAXIMUM_INCREMENTAL_MEMORY_BYTES, + maximumWriteBufferBytes: MAXIMUM_WRITE_BUFFER_BYTES +}) diff --git a/src/main/ssh/ssh-relay-artifact-tar-extraction.ts b/src/main/ssh/ssh-relay-artifact-tar-extraction.ts new file mode 100644 index 00000000000..09ba5921302 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-tar-extraction.ts @@ -0,0 +1,130 @@ +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 type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' + +type SelectedTuple = SshRelaySelectedArtifact['tuple'] + +function normalizedTarPath(entry: ReadEntry): string { + return entry.path.endsWith('/') ? entry.path.slice(0, -1) : entry.path +} + +function assertTarEntry( + entry: ReadEntry, + expected: ReadonlyMap, + seen: Set +): SelectedTuple['entries'][number] { + const path = normalizedTarPath(entry) + 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 expectedType = declared.type === 'file' ? 'File' : 'Directory' + if ( + entry.type !== expectedType || + entry.mode === undefined || + (entry.mode & 0o777) !== declared.mode + ) { + throw new Error(`SSH relay TAR type or mode mismatch: ${path}`) + } + if (declared.type === 'file' && entry.size !== declared.size) { + throw new Error(`SSH relay TAR size mismatch: ${path}`) + } + return declared +} + +async function decompressTar( + archivePath: string, + destination: NodeJS.WritableStream, + signal: AbortSignal, + chunkBytes: number +): Promise { + await pipeline( + createReadStream(archivePath, { highWaterMark: chunkBytes }), + createBrotliDecompress({ chunkSize: chunkBytes }), + destination, + { signal } + ) +} + +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, + tuple, + signal, + chunkBytes +}: { + archivePath: string + outputDirectory: string + tuple: SelectedTuple + signal: AbortSignal + chunkBytes: number +}): Promise { + const expected = new Map(tuple.entries.map((entry) => [entry.path, entry])) + const seen = new Set() + const unpack = extract({ + cwd: outputDirectory, + strict: true, + preserveOwner: false, + noChmod: false, + unlink: false, + filter: (_path, entry) => { + if (!(entry instanceof ReadEntry)) { + throw new Error('SSH relay TAR extraction requires a streamed archive entry') + } + assertTarEntry(entry, expected, seen) + return true + } + }) + await decompressTar(archivePath, unpack, 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}`) + } +} diff --git a/src/main/ssh/ssh-relay-artifact-tree-verification.ts b/src/main/ssh/ssh-relay-artifact-tree-verification.ts new file mode 100644 index 00000000000..9c8c84d24f4 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-tree-verification.ts @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat, readdir } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' + +import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' + +export type SshRelayArtifactTreeVerification = { + files: number + expandedBytes: number +} + +async function hashFile( + path: string, + expectedSize: number, + signal: AbortSignal, + chunkBytes: number +): 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') + } + digest.update(chunk) + } + if (size !== expectedSize) { + throw new Error('SSH relay extracted tree file size disagrees with its signed size') + } + return `sha256:${digest.digest('hex')}` +} + +export async function verifySshRelayArtifactTree({ + runtimeRoot, + tuple, + signal, + chunkBytes +}: { + runtimeRoot: string + tuple: SshRelaySelectedArtifact['tuple'] + signal: AbortSignal + chunkBytes: number +}): Promise { + const expected = new Map(tuple.entries.map((entry) => [entry.path, entry])) + const foldedPaths = new Set() + let files = 0 + let expandedBytes = 0 + + async function visit(directory: string): Promise { + signal.throwIfAborted() + const children = await readdir(directory, { withFileTypes: true }) + for (const child of children) { + signal.throwIfAborted() + const absolutePath = join(directory, child.name) + const path = relative(runtimeRoot, absolutePath).split(sep).join('/') + const folded = path.toLowerCase() + if (foldedPaths.has(folded)) { + throw new Error(`SSH relay extracted tree has a case-fold collision: ${path}`) + } + foldedPaths.add(folded) + const declared = expected.get(path) + if (!declared) { + throw new Error(`SSH relay extracted tree has an undeclared entry: ${path}`) + } + expected.delete(path) + const metadata = await lstat(absolutePath) + const actualType = metadata.isDirectory() + ? 'directory' + : metadata.isFile() + ? 'file' + : 'unsupported' + if (actualType !== declared.type) { + throw new Error(`SSH relay extracted tree type mismatch: ${path}`) + } + if (process.platform !== 'win32' && (metadata.mode & 0o777) !== declared.mode) { + throw new Error(`SSH relay extracted tree mode mismatch: ${path}`) + } + if (declared.type === 'directory') { + await visit(absolutePath) + continue + } + if (metadata.size !== declared.size) { + throw new Error(`SSH relay extracted tree size mismatch: ${path}`) + } + if ((await hashFile(absolutePath, declared.size, signal, chunkBytes)) !== declared.sha256) { + throw new Error(`SSH relay extracted tree integrity mismatch: ${path}`) + } + files += 1 + expandedBytes += declared.size + } + } + + await visit(runtimeRoot) + const missing = expected.keys().next().value as string | undefined + if (missing) { + throw new Error(`SSH relay extracted tree is missing a declared entry: ${missing}`) + } + if (files !== tuple.archive.fileCount || expandedBytes !== tuple.archive.expandedSize) { + throw new Error('SSH relay extracted tree aggregate size or file count is inconsistent') + } + return { files, expandedBytes } +} diff --git a/src/main/ssh/ssh-relay-artifact-zip-extraction.ts b/src/main/ssh/ssh-relay-artifact-zip-extraction.ts new file mode 100644 index 00000000000..f98f347584c --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-zip-extraction.ts @@ -0,0 +1,103 @@ +import { chmod, mkdir } from 'node:fs/promises' +import { join } from 'node:path' + +import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' +import { type SshRelayZipLimits, visitSshRelayZip } from './ssh-relay-artifact-zip-reader' + +type SelectedTuple = SshRelaySelectedArtifact['tuple'] + +function zipLimits(tuple: SelectedTuple): SshRelayZipLimits { + return { + maximumArchiveBytes: tuple.archive.size, + maximumEntries: tuple.entries.length, + maximumExpandedBytes: tuple.archive.expandedSize, + maximumFileBytes: Math.max( + ...tuple.entries.map((entry) => (entry.type === 'file' ? entry.size : 0)) + ), + maximumDepth: 32, + maximumPathBytes: 240 + } +} + +async function visitExpectedZip({ + archivePath, + tuple, + outputDirectory, + signal +}: { + archivePath: string + tuple: SelectedTuple + outputDirectory?: string + signal: AbortSignal +}): Promise { + const expected = new Map(tuple.entries.map((entry) => [entry.path, entry])) + const seen = new Set() + const result = await visitSshRelayZip( + archivePath, + zipLimits(tuple), + async (actual, consume) => { + const declared = expected.get(actual.path) + if (!declared || seen.has(actual.path)) { + throw new Error(`SSH relay ZIP has an extra or duplicate entry: ${actual.path}`) + } + seen.add(actual.path) + if ( + actual.type !== declared.type || + actual.unixMode === null || + (actual.unixMode & 0o777) !== declared.mode + ) { + throw new Error(`SSH relay ZIP type or mode mismatch: ${actual.path}`) + } + if (declared.type === 'directory') { + if (outputDirectory) { + const outputPath = join(outputDirectory, ...actual.path.split('/')) + await mkdir(outputPath, { recursive: true, mode: declared.mode }) + if (process.platform !== 'win32') { + await chmod(outputPath, declared.mode) + } + } + return + } + if (actual.size !== declared.size) { + throw new Error(`SSH relay ZIP size mismatch: ${actual.path}`) + } + const outputPath = outputDirectory + ? join(outputDirectory, ...actual.path.split('/')) + : undefined + if ((await consume({ outputPath, mode: declared.mode })) !== declared.sha256) { + throw new Error(`SSH relay ZIP file integrity mismatch: ${actual.path}`) + } + if (outputPath && process.platform !== 'win32') { + await chmod(outputPath, declared.mode) + } + }, + signal + ) + const missing = tuple.entries.find((entry) => !seen.has(entry.path)) + if (missing) { + throw new Error(`SSH relay ZIP is missing a declared entry: ${missing.path}`) + } + if ( + result.files !== tuple.archive.fileCount || + result.expandedBytes !== tuple.archive.expandedSize + ) { + throw new Error('SSH relay ZIP aggregate size or file count is inconsistent') + } +} + +export async function inspectSshRelayZip(options: { + archivePath: string + tuple: SelectedTuple + signal: AbortSignal +}): Promise { + await visitExpectedZip(options) +} + +export async function extractSshRelayZip(options: { + archivePath: string + outputDirectory: string + tuple: SelectedTuple + signal: AbortSignal +}): Promise { + await visitExpectedZip(options) +} diff --git a/src/main/ssh/ssh-relay-artifact-zip-reader.ts b/src/main/ssh/ssh-relay-artifact-zip-reader.ts new file mode 100644 index 00000000000..f0391fd60bc --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-zip-reader.ts @@ -0,0 +1,285 @@ +import { createHash } from 'node:crypto' +import type { EventEmitter } from 'node:events' +import { createWriteStream } from 'node:fs' +import { mkdir, stat } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname } from 'node:path' +import { type Readable, Transform, Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { crc32 } from 'node:zlib' + +type ZipEntry = { + fileName: Buffer + versionMadeBy: number + externalFileAttributes: number + generalPurposeBitFlag: number + compressionMethod: number + uncompressedSize: number + compressedSize: number + crc32: number +} + +type ZipFile = EventEmitter & { + readEntry(): void + close(): void + openReadStream(entry: ZipEntry, callback: (error: Error | null, stream?: Readable) => void): void +} + +type YauzlModule = { + open( + path: string, + options: { + autoClose: boolean + lazyEntries: boolean + decodeStrings: boolean + strictFileNames: boolean + validateEntrySizes: boolean + }, + callback: (error: Error | null, zip?: ZipFile) => void + ): void +} + +export type SshRelayZipLimits = { + maximumArchiveBytes: number + maximumEntries: number + maximumExpandedBytes: number + maximumFileBytes: number + maximumDepth: number + maximumPathBytes: number +} + +export type SshRelayZipEntryInfo = { + path: string + type: 'directory' | 'file' + size: number + crc32: number + unixMode: number | null +} + +const yauzl = createRequire(import.meta.url)('yauzl') as YauzlModule +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) +const ENCRYPTION_FLAGS = 0x0001 | 0x0040 | 0x2000 +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i + +function openZip(path: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open( + path, + { + autoClose: false, + lazyEntries: true, + decodeStrings: false, + strictFileNames: true, + validateEntrySizes: true + }, + (error, zip) => + error ? reject(error) : zip ? resolve(zip) : reject(new Error('ZIP missing')) + ) + }) +} + +function nextEntry(zip: ZipFile): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + zip.off('entry', onEntry) + zip.off('end', onEnd) + zip.off('error', onError) + } + const onEntry = (entry: ZipEntry): void => { + cleanup() + resolve(entry) + } + const onEnd = (): void => { + cleanup() + resolve(null) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + zip.once('entry', onEntry) + zip.once('end', onEnd) + zip.once('error', onError) + zip.readEntry() + }) +} + +function openEntryStream(zip: ZipFile, entry: ZipEntry): Promise { + return new Promise((resolve, reject) => { + zip.openReadStream(entry, (error, stream) => + error ? reject(error) : stream ? resolve(stream) : reject(new Error('ZIP stream missing')) + ) + }) +} + +function decodeEntryName(entry: ZipEntry): string { + try { + return UTF8_DECODER.decode(entry.fileName) + } catch { + throw new Error('SSH relay ZIP entry name must be valid UTF-8') + } +} + +function portableEntryPath(name: string, limits: SshRelayZipLimits): string { + if ( + !name || + Buffer.byteLength(name) > limits.maximumPathBytes || + name.startsWith('/') || + name.startsWith('\\') || + /^[A-Za-z]:/.test(name) + ) { + throw new Error('SSH relay ZIP entry path is not a bounded portable relative path') + } + for (const character of name) { + const code = character.charCodeAt(0) + if (code <= 0x1f || code === 0x7f || character === '\\') { + throw new Error('SSH relay ZIP entry path contains a control character or backslash') + } + } + const path = name.endsWith('/') ? name.slice(0, -1) : name + const segments = path.split('/') + if ( + segments.length > limits.maximumDepth || + segments.some( + (segment) => + !segment || + segment === '.' || + segment === '..' || + segment.includes(':') || + segment.endsWith('.') || + segment.endsWith(' ') || + WINDOWS_DEVICE_NAME.test(segment) + ) + ) { + throw new Error('SSH relay ZIP entry path contains an unsafe path segment') + } + return path +} + +function entryInfo(entry: ZipEntry, path: string): SshRelayZipEntryInfo { + const hostSystem = entry.versionMadeBy >>> 8 + const unixMode = hostSystem === 3 ? (entry.externalFileAttributes >>> 16) & 0xffff : null + if (unixMode !== null && (unixMode & 0o170000) === 0o120000) { + throw new Error(`SSH relay ZIP contains a prohibited symbolic link: ${path}`) + } + if ((entry.generalPurposeBitFlag & ENCRYPTION_FLAGS) !== 0) { + throw new Error(`SSH relay ZIP contains an encrypted entry: ${path}`) + } + if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) { + throw new Error(`SSH relay ZIP uses an unsupported compression method: ${path}`) + } + const directory = entry.fileName.at(-1) === 0x2f + if (directory && (entry.uncompressedSize !== 0 || entry.compressedSize !== 0)) { + throw new Error(`SSH relay ZIP directory contains file data: ${path}`) + } + return { + path, + type: directory ? 'directory' : 'file', + size: entry.uncompressedSize, + crc32: entry.crc32 >>> 0, + unixMode + } +} + +async function consumeEntry({ + zip, + entry, + info, + outputPath, + mode, + signal +}: { + zip: ZipFile + entry: ZipEntry + info: SshRelayZipEntryInfo + outputPath?: string + mode?: number + signal: AbortSignal +}): Promise { + const input = await openEntryStream(zip, entry) + let bytes = 0 + let checksum = 0 + const digest = createHash('sha256') + const verifier = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > info.size) { + callback(new Error(`SSH relay ZIP entry expanded beyond its signed size: ${info.path}`)) + return + } + checksum = crc32(chunk, checksum) + digest.update(chunk) + callback(null, chunk) + } + }) + const sink = outputPath + ? (await mkdir(dirname(outputPath), { recursive: true }), + createWriteStream(outputPath, { flags: 'wx', mode: mode ?? 0o600 })) + : new Writable({ write: (_chunk, _encoding, callback) => callback() }) + await pipeline(input, verifier, sink, { signal }) + if (bytes !== info.size || checksum >>> 0 !== info.crc32) { + throw new Error(`SSH relay ZIP entry size or CRC-32 mismatch: ${info.path}`) + } + return `sha256:${digest.digest('hex')}` +} + +export async function visitSshRelayZip( + archivePath: string, + limits: SshRelayZipLimits, + visitor: ( + actual: SshRelayZipEntryInfo, + consume: (options?: { outputPath?: string; mode?: number }) => Promise + ) => Promise, + signal: AbortSignal +): Promise<{ files: number; expandedBytes: number }> { + const metadata = await stat(archivePath) + if (!metadata.isFile() || metadata.size === 0 || metadata.size > limits.maximumArchiveBytes) { + throw new Error('SSH relay ZIP exceeds its compressed-size limit') + } + const zip = await openZip(archivePath) + const foldedPaths = new Set() + let entries = 0 + let files = 0 + let expandedBytes = 0 + try { + for (let entry = await nextEntry(zip); entry; entry = await nextEntry(zip)) { + signal.throwIfAborted() + const path = portableEntryPath(decodeEntryName(entry), limits) + const folded = path.toLowerCase() + if (foldedPaths.has(folded)) { + throw new Error(`SSH relay ZIP has a duplicate or case-fold collision: ${path}`) + } + foldedPaths.add(folded) + const info = entryInfo(entry, path) + entries += 1 + if (entries > limits.maximumEntries) { + throw new Error('SSH relay ZIP exceeds the entry-count limit') + } + if (info.type === 'file') { + files += 1 + if (!Number.isSafeInteger(info.size) || info.size > limits.maximumFileBytes) { + throw new Error(`SSH relay ZIP file exceeds the per-file size limit: ${path}`) + } + expandedBytes += info.size + if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.maximumExpandedBytes) { + throw new Error('SSH relay ZIP exceeds the expanded-size limit') + } + } + let consumed = info.type === 'directory' + const consume = async (options?: { outputPath?: string; mode?: number }): Promise => { + if (consumed) { + throw new Error(`SSH relay ZIP entry was consumed more than once: ${path}`) + } + consumed = true + return consumeEntry({ zip, entry, info, ...options, signal }) + } + await visitor(info, consume) + if (!consumed) { + throw new Error(`SSH relay ZIP visitor did not verify file bytes: ${path}`) + } + } + return { files, expandedBytes } + } finally { + zip.close() + } +}