mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
feat(ssh): add verified relay artifact extraction
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"')
|
||||
|
||||
+19
-8
@@ -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.
|
||||
|
||||
|
||||
+230
-10
@@ -8,8 +8,8 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps
|
||||
|
||||
Date created: 2026-07-14<br>
|
||||
Last updated: 2026-07-15<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
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.<br>
|
||||
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)<br>
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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<string, Buffer>,
|
||||
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<string, Buffer>,
|
||||
{
|
||||
extraPath,
|
||||
symlinkPath
|
||||
}: {
|
||||
extraPath?: string
|
||||
symlinkPath?: string
|
||||
}
|
||||
): Promise<Buffer> {
|
||||
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<string, Buffer>
|
||||
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<Fixture> {
|
||||
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<string, Buffer>()
|
||||
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<void> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<SshRelayArtifactExtractionResult> {
|
||||
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
|
||||
})
|
||||
@@ -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<string, SelectedTuple['entries'][number]>,
|
||||
seen: Set<string>
|
||||
): 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<void> {
|
||||
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<void> {
|
||||
const expected = new Map(tuple.entries.map((entry) => [entry.path, entry]))
|
||||
const seen = new Set<string>()
|
||||
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<void> {
|
||||
const expected = new Map(tuple.entries.map((entry) => [entry.path, entry]))
|
||||
const seen = new Set<string>()
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<SshRelayArtifactTreeVerification> {
|
||||
const expected = new Map(tuple.entries.map((entry) => [entry.path, entry]))
|
||||
const foldedPaths = new Set<string>()
|
||||
let files = 0
|
||||
let expandedBytes = 0
|
||||
|
||||
async function visit(directory: string): Promise<void> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<void> {
|
||||
const expected = new Map(tuple.entries.map((entry) => [entry.path, entry]))
|
||||
const seen = new Set<string>()
|
||||
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<void> {
|
||||
await visitExpectedZip(options)
|
||||
}
|
||||
|
||||
export async function extractSshRelayZip(options: {
|
||||
archivePath: string
|
||||
outputDirectory: string
|
||||
tuple: SelectedTuple
|
||||
signal: AbortSignal
|
||||
}): Promise<void> {
|
||||
await visitExpectedZip(options)
|
||||
}
|
||||
@@ -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<ZipFile> {
|
||||
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<ZipEntry | null> {
|
||||
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<Readable> {
|
||||
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<string> {
|
||||
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<string>
|
||||
) => Promise<void>,
|
||||
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<string>()
|
||||
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<string> => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user