chore(docs): merge captured main into canonical guide layout
This commit is contained in:
commit
0b46590f8f
|
|
@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json
|
|||
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
|
||||
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
|
||||
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
|
||||
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
|
||||
For unsupported clients, use version-pinned manual stdio registration or CLI.
|
||||
|
||||
|
|
|
|||
|
|
@ -224,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
|||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
that address. Hopper and IDA refuse this interpretation; Ghidra uses
|
||||
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
|
||||
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/providers/raw-binary.md).
|
||||
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
|
||||
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
|
||||
containers, bank switching, and mapper semantics are unsupported. Windows
|
||||
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/providers/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ runs:
|
|||
steps:
|
||||
- name: Download portable build outputs from this workflow run
|
||||
if: inputs.build_cache_artifact != ''
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: ${{ inputs.build_cache_artifact }}
|
||||
path: .turbo/cache/
|
||||
|
|
@ -39,14 +39,14 @@ runs:
|
|||
' >> "$GITHUB_OUTPUT"
|
||||
- name: Restore portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
restore-keys: ${{ steps.plan.outputs.prefix }}
|
||||
- name: Restore and save portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
steps:
|
||||
# Consume only API data and execute batch code from the trusted default branch.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Record queue, execution, and matched step timing
|
||||
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
- name: Upload CI timing artifacts
|
||||
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ci-job-timings-batch
|
||||
path: .cache/ci-job-timings/
|
||||
|
|
|
|||
|
|
@ -63,11 +63,11 @@ jobs:
|
|||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
test_selection: ${{ steps.plan.outputs.test_selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Bind qualification to the caller revision
|
||||
|
|
@ -118,8 +118,8 @@ jobs:
|
|||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
run: npm run format:check
|
||||
- name: Upload complete static diagnostics
|
||||
if: always() && needs.changes.outputs.code == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: static-check-reports
|
||||
path: .cache/static-checks/
|
||||
|
|
@ -157,13 +157,13 @@ jobs:
|
|||
matrix:
|
||||
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
|
|
@ -177,7 +177,7 @@ jobs:
|
|||
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: vitest-report-${{ strategy.job-index }}
|
||||
|
|
@ -208,8 +208,8 @@ jobs:
|
|||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -231,13 +231,13 @@ jobs:
|
|||
- name: Download previous timing report
|
||||
if: steps.history.outputs.run-id != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: test-timings
|
||||
run-id: ${{ steps.history.outputs.run-id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: .cache/ci-test-history
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: |
|
||||
|
|
@ -254,7 +254,7 @@ jobs:
|
|||
# test:prepare caches only portable JS, skill, and catalog outputs.
|
||||
# Native package jobs still run their build and prepack checks; Turbo
|
||||
# restores matching outputs or rebuilds when their inputs differ.
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
|
|
@ -280,16 +280,16 @@ jobs:
|
|||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- if: needs.changes.outputs.code == 'true' && success()
|
||||
run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
path: .vitest/blob
|
||||
|
|
@ -330,7 +330,7 @@ jobs:
|
|||
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: node scripts/ci/test-report.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
with:
|
||||
name: test-results
|
||||
|
|
@ -345,7 +345,7 @@ jobs:
|
|||
retention-days: 30
|
||||
- name: Publish successful timing history
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-timings
|
||||
path: .cache/ci-test-history/test-results.json
|
||||
|
|
@ -366,7 +366,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Verify fixtures from a clean checkout
|
||||
run: npm run verify:fixtures
|
||||
|
||||
|
|
@ -377,8 +377,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
|
|
@ -395,12 +395,12 @@ jobs:
|
|||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- run: node scripts/build-windows-native.mjs --inspection-fixture
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -423,8 +423,8 @@ jobs:
|
|||
NODE_OPTIONS: "--max-old-space-size=1024"
|
||||
UV_THREADPOOL_SIZE: "2"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -438,11 +438,11 @@ jobs:
|
|||
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
|
||||
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -463,7 +463,7 @@ jobs:
|
|||
run: npm run verify:inspector
|
||||
- name: Retain the exact qualified release package
|
||||
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
|
|
@ -479,13 +479,13 @@ jobs:
|
|||
runs-on: macos-15
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
|
|
@ -527,8 +527,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -539,7 +539,7 @@ jobs:
|
|||
run: |
|
||||
set -o pipefail
|
||||
npm run verify:readiness | tee readiness-summary.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: reconstruction-readiness
|
||||
|
|
@ -556,18 +556,18 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- run: npm run build:cached
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -623,7 +623,7 @@ jobs:
|
|||
tests/process-global/updateMaintenance.test.ts
|
||||
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
|
||||
- run: npm run verify:windows-native
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
|
|
@ -642,7 +642,7 @@ jobs:
|
|||
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
|
||||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
- name: Verify native path boundaries on Node 22
|
||||
|
|
@ -652,7 +652,7 @@ jobs:
|
|||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
tests/boundary/process/sourceMapUncHosts.test.ts
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-capabilities
|
||||
|
|
@ -666,8 +666,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -677,7 +677,7 @@ jobs:
|
|||
run: npm run verify:docs:routes
|
||||
- name: Generation leaves authored files unchanged
|
||||
run: git diff --exit-code
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: generated-docs
|
||||
path: |
|
||||
|
|
@ -784,8 +784,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Require every selected lane to pass
|
||||
|
|
@ -796,7 +796,7 @@ jobs:
|
|||
run: node scripts/ci/required.mjs
|
||||
- name: Upload full qualification receipt artifact
|
||||
if: needs.changes.outputs.tier == 'full' && success()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ steps.gate.outputs.receipt_name }}
|
||||
path: ${{ steps.gate.outputs.receipt_path }}
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ jobs:
|
|||
DOTNET_PROCESSOR_COUNT: "1"
|
||||
COMPlus_GCHeapHardLimit: "30000000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: "8.0.416"
|
||||
- name: Build benign fixture and independent byte oracle
|
||||
run: node scripts/generate-nativeaot-fixtures.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ jobs:
|
|||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
|
|
@ -45,12 +45,12 @@ jobs:
|
|||
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
env:
|
||||
REA_ANALYSIS_PROVIDER: ghidra
|
||||
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-ghidra-proof
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -36,8 +36,8 @@ jobs:
|
|||
runs-on: [self-hosted, Linux, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ jobs:
|
|||
runs-on: [self-hosted, macOS, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Download the pinned Termux app and verify its release checksum
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Build and capture a page inside Termux
|
||||
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: 30
|
||||
arch: x86_64
|
||||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
script: bash scripts/verify/termux/emulator.sh
|
||||
- name: Upload Termux and Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: termux-browser-diagnostics
|
||||
path: ${{ runner.temp }}/rea-termux/diagnostics
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
echo "frozen=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
if: steps.editorial.outputs.frozen != 'true'
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -70,13 +70,13 @@ jobs:
|
|||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- name: Check out reviewed release merge
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -104,7 +104,7 @@ jobs:
|
|||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -191,14 +191,14 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Check out release controller and Git history
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for checkpoint validation
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -226,7 +226,7 @@ jobs:
|
|||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
# A dedicated credential starts PR CI automatically; the built-in
|
||||
|
|
@ -255,7 +255,7 @@ jobs:
|
|||
|
||||
- name: Check out release pull request
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
fetch-depth: 0
|
||||
|
|
@ -263,7 +263,7 @@ jobs:
|
|||
|
||||
- name: Set up Node.js for generated documentation
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -314,12 +314,12 @@ jobs:
|
|||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -328,7 +328,7 @@ jobs:
|
|||
- run: npm ci
|
||||
|
||||
- name: Download the exact qualified package
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
|
|
@ -392,7 +392,7 @@ jobs:
|
|||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -22,14 +22,14 @@ jobs:
|
|||
# The repository-pinned Node 24 version also runs the complete test suite.
|
||||
node: ["22.x", "24.15.0", "24.x", "26.x"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
check-latest: true
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
|
@ -74,9 +74,9 @@ jobs:
|
|||
)
|
||||
PY
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
- name: Upload the website
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
with:
|
||||
path: website/_site
|
||||
|
||||
|
|
@ -95,15 +95,15 @@ jobs:
|
|||
steps:
|
||||
- name: Check out publishing configuration
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Node.js for Wrangler
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Download the verified website
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: github-pages
|
||||
path: ${{ runner.temp }}/website-archive
|
||||
|
|
@ -114,7 +114,7 @@ jobs:
|
|||
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
|
||||
- name: Publish the website to Cloudflare
|
||||
if: inputs.target == 'both'
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
|
@ -123,7 +123,7 @@ jobs:
|
|||
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
|
||||
- name: Publish to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
- name: Verify the selected published revisions
|
||||
env:
|
||||
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack
|
|||
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
|
||||
protocols in adapters and shared CLI/MCP workflows in the application layer.
|
||||
|
||||
### Windows source checkouts
|
||||
|
||||
The published npm package includes the Windows x64 native addon and its
|
||||
manifest. A source checkout does not: `native/windows/build/` is ignored by
|
||||
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
|
||||
produces `rea-windows-x64.node` or `manifest.json` in that directory.
|
||||
Windows workflows and tests that require native controls need these artifacts;
|
||||
without them, those controls are unavailable even after a successful runtime build.
|
||||
|
||||
Follow the [Windows native build guide](native/windows/README.md#build) before
|
||||
running those workflows from source. The artifact build requires Linux,
|
||||
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
|
||||
for the build and artifact-verification commands and the separate real Windows
|
||||
verification prerequisites. Keep the generated addon and manifest uncommitted.
|
||||
|
||||
## Documentation website
|
||||
|
||||
Use the [documentation directory](docs/README.md) and
|
||||
|
|
|
|||
|
|
@ -812,7 +812,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
return functionObservation(result, boundaryLimitations);
|
||||
}
|
||||
|
||||
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
|
|
@ -825,7 +825,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
for (Function item : ordered) {
|
||||
result.add(canonicalAddress(item.getEntryPoint()));
|
||||
}
|
||||
return result;
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject procedureInfo(JsonObject params) throws Exception {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
|
||||
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
|
||||
public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
|
|
@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
|
|||
);
|
||||
}
|
||||
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
|
||||
Address start = currentProgram.getMinAddress();
|
||||
if (entry == null || !entry.equals(start)) {
|
||||
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
|
||||
boolean fileStart = false;
|
||||
if (block != null) {
|
||||
// Processor-defined zero page/stack blocks can precede the imported file.
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
|
||||
entry.equals(source.getMinAddress())) {
|
||||
fileStart = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fileStart) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary import must map file offset 0 at the caller-supplied entry"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,14 +18,26 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
|
|||
result that its client offloads, and Pi exercises default codemode execution.
|
||||
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||
Receipts retain client versions, result digests, host coverage and owned-process
|
||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
||||
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
|
||||
cleanup. These lanes use a deterministic loopback model and do not prove live
|
||||
model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes
|
||||
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
|
||||
the shared personal skill through their default search paths without an explicit
|
||||
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
|
||||
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
|
||||
a named sticky profile with `HERMES_HOME` still pointing to its root.
|
||||
|
||||
The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and
|
||||
Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call`
|
||||
journey reached full Evidence readback but the client aborted during exit with
|
||||
`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim.
|
||||
Select the npm install's
|
||||
`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`,
|
||||
because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`,
|
||||
`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks
|
||||
use native separators; persisted output is read through Qwen's real shell tool
|
||||
with a shell-independent encoded Node payload. Windows receipts report process
|
||||
lineage as unavailable when the verifier has no retained native Job Object lease.
|
||||
|
||||
`verify:gemini-client` requires an installed Gemini CLI (verified with
|
||||
`@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with
|
||||
`REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs
|
||||
|
|
|
|||
|
|
@ -734,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
|
|||
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
|
||||
runtime or PC-98 device execution.
|
||||
|
||||
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
|
||||
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
|
||||
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
|
||||
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
|
||||
mapping parity, exact source bytes, functions, a direct call reference,
|
||||
decompilation, 16-bit overflow rejection without replacing the active session,
|
||||
and runtime cleanup. Windows additionally requires the matching native addon;
|
||||
its verifier lineage remains explicitly unavailable outside the provider's
|
||||
retained Job Object, as in the other Windows lanes.
|
||||
|
||||
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
|
||||
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
|
||||
prerequisites, it checks preparation and bridge startup, verified load mappings,
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
|
|||
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
|
||||
```
|
||||
|
||||
An exact CLI cache hit is read before starting a provider process.
|
||||
An exact CLI cache hit is normally read before starting a provider process.
|
||||
On Windows, Ghidra first admits the selected source through its native snapshot
|
||||
owner, so replay can start that provider.
|
||||
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
|
||||
for its matching target; an MCP provider may still start before returning a
|
||||
cached result. `close_binary` accepts `snapshot_path` and optional
|
||||
|
|
|
|||
|
|
@ -165,6 +165,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
|
|||
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
|
||||
installs its skill copy there.
|
||||
|
||||
## Factory Droid
|
||||
|
||||
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
|
||||
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
|
||||
`command`, `args`, and `env`. Droid discovers personal skills from
|
||||
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
|
||||
setup installs its skill copy there.
|
||||
|
||||
## Trae
|
||||
|
||||
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
|||
| Pi | `pi` |
|
||||
| Hermes | `hermes` |
|
||||
| Qoder | `qoder` |
|
||||
| Factory Droid | `droid` |
|
||||
| Trae | `trae` |
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
|
|
@ -512,7 +513,10 @@ and `set_addresses_names` name a function entry or any other mapped address
|
|||
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
require Hopper; Windows P0 remains read-only.
|
||||
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
|
||||
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
|
||||
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
|
||||
unsupported on Windows.
|
||||
The npm package bundles native Job Object ownership, protected private runtime
|
||||
DACLs, and handle-based path admission; no separate addon installation is needed.
|
||||
See the [Windows Ghidra P0 guide](../providers/windows-ghidra-p0.md) for verified scope and
|
||||
|
|
|
|||
|
|
@ -208,6 +208,18 @@ arrays, and later explicit data properties and methods replace earlier object
|
|||
spread references. A known array prefix does not expose values from a following
|
||||
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
|
||||
unknown spread lengths retain conservative uncertainty.
|
||||
Getter-local aliases of `this`, including direct destructuring and assignments,
|
||||
retain their receiver when a returned shared reference is written. These writes
|
||||
invalidate the earlier literal instead of claiming the getter executed or the
|
||||
new value is known. Straight-line alias reads retain their capture order;
|
||||
uncertain control flow remains conservative. A binding outside the getter or
|
||||
`this` in a nested ordinary function is not attributed to the getter's receiver.
|
||||
Private reads keep receiver projections separate from the private value's path.
|
||||
Receiver projections follow known data fields initialized from `this` or earlier
|
||||
self-referencing fields; static fields retain their declaring class identity.
|
||||
An alias cycle that repeatedly selects deeper children retains escape uncertainty
|
||||
at its recovered roots rather than claiming a fixed number of loop iterations.
|
||||
|
||||
Writes through an object getter's returned receiver retain that uncertainty
|
||||
when the object is an array element, nested inside mixed object/array
|
||||
containers, or assigned to a previously declared identifier. Saving the getter
|
||||
|
|
|
|||
|
|
@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their
|
|||
selected scope as partial. Neither makes an absence claim about the whole
|
||||
assembly. Older member Evidence without `selection` remains readable.
|
||||
|
||||
### Edge target declaring types
|
||||
|
||||
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
|
||||
`field_accesses[].field_declaring_type` name the type that declares each
|
||||
target. Referenced spec signatures validate encoded type coordinates against
|
||||
the metadata tables, including nested type arguments and custom modifiers.
|
||||
TypeRef resolution scopes are checked transitively with cycle detection;
|
||||
readable names survive malformed scopes as partial evidence.
|
||||
TypeDef and TypeRef names use the namespace-qualified `full_name`
|
||||
form, and nested types keep bare names. A generic-instantiation TypeSpec
|
||||
parent names its generic type, such as ``System.Collections.Generic.List`1``.
|
||||
Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay
|
||||
`null`. A `method-spec` call edge reports the name and declaring type of the
|
||||
generic method it instantiates. A field access is named only by a field or a
|
||||
field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the
|
||||
member-signature grammar, and a MethodSpec must instantiate a generic method
|
||||
with as many type arguments as it declares. Malformed or unsupported rows and
|
||||
blobs, and arity mismatches, make coverage partial. A readable MethodSpec
|
||||
method column that names a method, or a TypeRef whose resolution scope is
|
||||
invalid, keeps its name.
|
||||
Older member Evidence without these fields reads them as `null`: unknown.
|
||||
|
||||
## Analysis objective
|
||||
|
||||
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
|
||||
|
|
|
|||
|
|
@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
|
|||
CLI output are unchanged.
|
||||
|
||||
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
|
||||
and 30-second owned command deadline. The reply budget applies to the decoder
|
||||
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
|
||||
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
|
||||
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
|
||||
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
|
||||
copies and encoding overhead. The
|
||||
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
|
||||
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
|
||||
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](../../reference/mcp-contracts.md#tool-results).
|
||||
|
|
|
|||
|
|
@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason.
|
|||
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
||||
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
|
||||
the matching native decompiler. Experimental Windows x64 P0 admits native
|
||||
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
|
||||
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](../../providers/windows-ghidra-p0.md) and
|
||||
x86 and x86-64 PE applications and DLLs, plus explicitly selected
|
||||
[raw NMOS 6502 images](../../providers/raw-binary.md#nmos-6502) with a caller-supplied load address,
|
||||
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
|
||||
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
|
||||
handle-based path admission. See [Windows Ghidra P0](../../providers/windows-ghidra-p0.md) and
|
||||
[issue #527](https://github.com/morluto/rea/issues/527).
|
||||
On Linux and macOS, `annotate_native_function` atomically edits a function name
|
||||
and entry comments in the ephemeral database, returning refreshed analysis
|
||||
|
|
|
|||
|
|
@ -72,9 +72,13 @@ debugger context is `observed`.
|
|||
|
||||
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
|
||||
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
|
||||
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
|
||||
hard limits are not raised. Complete oversized results fail explicitly. These
|
||||
process limits do not constitute a sandbox for caller-supplied engine code.
|
||||
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
|
||||
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
|
||||
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
|
||||
large core that outruns them still fails explicitly instead of partially. Tighter
|
||||
inherited soft/hard limits are retained; hard limits are not raised. Complete
|
||||
oversized results fail explicitly. These process limits do not constitute a sandbox
|
||||
for caller-supplied engine code.
|
||||
|
||||
Resource exit statuses need matching private failure-branch markers. Unmarked
|
||||
launcher failures retain their actual status/output and unknown resource cause.
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
|
|||
interpretation changes invalidate profile-bound snapshots. The support-lane
|
||||
identity is versioned independently of file identity. Evidence, lifecycle
|
||||
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
|
||||
|
||||
### Soft-float prototype
|
||||
|
||||
|
|
|
|||
|
|
@ -131,9 +131,10 @@ overview merely because its source artifact is unchanged.
|
|||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
|
||||
observed terminal call sites in Evidence limitations, including resolved callee
|
||||
names, addresses, and FunctionManager flags when available. Instruction windows
|
||||
Pseudocode, assembly, procedure info, instruction windows, callee and caller
|
||||
reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
including resolved callee names, addresses, and FunctionManager flags when
|
||||
available. Instruction windows
|
||||
and dossiers also retain these warnings in their result limitations. An
|
||||
unresolved callee or unestablished terminal-flow basis remains explicit. These
|
||||
observations qualify the analysis; they do not prove that a no-return flag is
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
|
|||
target also retains the raw ELF declarations that decide a lane: class, byte
|
||||
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
|
||||
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
|
||||
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
|
||||
its existing four families.
|
||||
are unchanged. Headerless inputs use the separate [raw-binary admission
|
||||
boundary](raw-binary.md); raw RISC-V images are not supported.
|
||||
|
||||
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
|
||||
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
|
|||
}
|
||||
```
|
||||
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
|
||||
hexadecimal address of the form `0x…`. The complete file is mapped starting at
|
||||
that address, and Ghidra seeds an `entry` function there before auto-analysis.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
|
||||
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
|
||||
images that cross the 16-bit boundary are rejected before provider startup.
|
||||
`open_binary` and an open `binary_session` return the canonical `entry_point`
|
||||
for raw targets so callers can verify the selected mapping directly.
|
||||
Omitted `format` keeps header-based detection; architecture and entry without
|
||||
|
|
@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without
|
|||
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
|
||||
generic languages and compiler specifications:
|
||||
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | ------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | -------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| 6502 | `6502:LE:16:default` | `default` |
|
||||
|
||||
Compiler specifications select analysis conventions; they do not identify the
|
||||
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
|
||||
|
|
@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains
|
|||
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
|
||||
points, including unmapped ARM vectors. Load-image verification requires the
|
||||
caller-supplied entry and preserves those other entries as observations.
|
||||
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
|
||||
and stack blocks. Conflicting default blocks are skipped; low-address files stay
|
||||
in the default address space rather than an overlay. Non-conflicting uninitialized
|
||||
blocks remain observations, not file-backed bytes.
|
||||
Independent raw verification streams the complete private snapshot digest and
|
||||
extent with bounded memory and supports cancellation during the read.
|
||||
It also requires the source-backed block to be initialized, loaded, and
|
||||
non-overlay in the bound language's default address space. Contradictory mapping
|
||||
observations are retained with `mismatch`, even when the source hashes agree.
|
||||
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
|
||||
native PE applications and DLLs only; raw-binary targets are unsupported.
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
|
||||
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
|
||||
remain outside the Windows boundary. Native path admission, private DACLs, Job
|
||||
Object ownership, immutable snapshots, and the no-mutation policy still apply.
|
||||
|
||||
## NMOS 6502
|
||||
|
||||
```bash
|
||||
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
```
|
||||
|
||||
Select only headerless NMOS 6502 code with a known entry at file offset 0.
|
||||
The built-in Ghidra processor supplies static instructions, functions,
|
||||
pseudocode, and references. Evidence preserves `architecture: "6502"`, the
|
||||
selected language, entry, file digest, and verified source mapping. Register and
|
||||
calling conventions are analysis conventions, not a recovered source ABI.
|
||||
|
||||
This lane does not interpret iNES headers, cartridge mappers, bank switching,
|
||||
or other console-specific memory layouts. 65C02 and 65816 programs need different
|
||||
processor semantics and are not admitted as those variants. REA does not infer
|
||||
the CPU from the file suffix, guess the reset vector, or emulate the target.
|
||||
|
||||
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
|
||||
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
|
||||
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
|
||||
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
|
||||
invalid mappings, unchanged source bytes and runtime cleanup.
|
||||
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
|
||||
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
|
||||
both CLI and MCP. On Windows, the lane also checks that a caller-selected
|
||||
junction path is rejected at snapshot admission through CLI and MCP, with
|
||||
unchanged source bytes and complete runtime cleanup. Windows requires the
|
||||
matching native addon; the POSIX provider path has not been exercised by the
|
||||
Windows run.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
|
|
|||
|
|
@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
|
|||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
|
||||
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
|
||||
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
|
||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||
|
||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||
PE load-image inspection returns an explicit unsupported attestation status.
|
||||
Independent verification covers admitted DOS MZ, explicit COM, and explicit
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
|
||||
extend Windows P0 admission beyond native PE. For x64 PE
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts, including the
|
||||
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
|
|
@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
|
|||
operations remain unavailable through this provider. Managed PE/CLI inspection
|
||||
uses its separate execution-free provider.
|
||||
|
||||
Raw 6502 imports retain the same native filesystem and process ownership
|
||||
requirements. The complete image must fit in the 16-bit address space; no
|
||||
console-specific cartridge loader, CPU variant, or bank-switching model is
|
||||
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
|
||||
to check the source-built fixture through real CLI and MCP workflows.
|
||||
|
||||
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
|
||||
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
|
||||
PyGhidra are not prerequisites. Users do not build the native addon.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ def main():
|
|||
if undeclared:
|
||||
raise ValueError('Declare inherited provider settings explicitly in --providers: '
|
||||
+ ', '.join(undeclared) + '. Values are not logged.')
|
||||
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
|
||||
else Path.home() / '.codex')
|
||||
source_auth = source_auth_home / 'auth.json'
|
||||
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
runtime_files, runtime_digest = runtime_fingerprint(repository)
|
||||
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
|
||||
|
|
@ -103,7 +106,6 @@ def main():
|
|||
workspace = profile / 'workspace'
|
||||
for directory in [trial_codex_home, account, workspace]:
|
||||
directory.mkdir(mode=0o700)
|
||||
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
|
||||
auth = trial_codex_home / 'auth.json'
|
||||
started = time.monotonic()
|
||||
timeout = False
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
|
|||
self.assertNotIn(marker, text)
|
||||
self.assertIn('caller-selected evidence and paths', text)
|
||||
self.assertIn('original-response', text)
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
# Windows mode bits do not describe access control; this is a POSIX assertion.
|
||||
if os.name == 'posix':
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
|
|||
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
|
||||
patch.object(agent.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
|
||||
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
|
||||
agent.main()
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@
|
|||
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
|
||||
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
|
||||
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
|
||||
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
|
||||
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
|
||||
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
|
|
@ -18,10 +18,6 @@ import {
|
|||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||
|
|
@ -32,6 +28,13 @@ assert(
|
|||
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||
);
|
||||
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
|
||||
// before the verifier switches to its disposable workspace.
|
||||
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
|
||||
const launch = (args) =>
|
||||
entryPoint === undefined
|
||||
? [command, args]
|
||||
: [process.execPath, [entryPoint, ...args]];
|
||||
const verifier = createVerifierRun();
|
||||
const profile = join(lab, "selected profile");
|
||||
const account = join(lab, "account");
|
||||
|
|
@ -54,14 +57,18 @@ const baseEnvironment = {
|
|||
let clientVersion;
|
||||
try {
|
||||
clientVersion = (
|
||||
await exec(command, ["--version"], {
|
||||
await exec(...launch(["--version"]), {
|
||||
env: baseEnvironment,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const original = JSON.stringify(
|
||||
|
|
@ -93,8 +100,8 @@ for (const step of [
|
|||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + "/") ||
|
||||
a.target.startsWith(sharedSkills + "/"),
|
||||
a.target.startsWith(profile + sep) ||
|
||||
a.target.startsWith(sharedSkills + sep),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
|
|
@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({
|
|||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||
const messageText = (body.messages ?? [])
|
||||
.map((message) =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
selectedSkillLoaded ||= messageText.includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const toolText = JSON.stringify(
|
||||
|
|
@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({
|
|||
const invoke = tools.find((t) => t.name === "tool_call");
|
||||
const skill = tools.find((t) => t.name === "skill");
|
||||
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||
const persisted = toolText.match(
|
||||
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||
);
|
||||
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
|
|
@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({
|
|||
artifactPath = persisted[1];
|
||||
assert(
|
||||
(await realpath(artifactPath)).startsWith(
|
||||
(await realpath(runtime)) + "/",
|
||||
(await realpath(runtime)) + sep,
|
||||
),
|
||||
);
|
||||
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
|
||||
const encodedScript = Buffer.from(script).toString("base64");
|
||||
message = call(shell.name, {
|
||||
command: `node -e ${quote(script)}`,
|
||||
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
|
||||
description:
|
||||
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||
});
|
||||
|
|
@ -329,7 +342,7 @@ const args = [
|
|||
let response;
|
||||
let failure;
|
||||
try {
|
||||
response = await exec(command, args, {
|
||||
response = await exec(...launch(args), {
|
||||
env,
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,479 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const ca65 = process.env.REA_CA65 ?? "ca65";
|
||||
const ld65 = process.env.REA_LD65 ?? "ld65";
|
||||
const toolchain = {};
|
||||
for (const [name, command] of Object.entries({ ca65, ld65 })) {
|
||||
const version = await exec(command, ["--version"], { timeout: 10000 });
|
||||
toolchain[name] = (version.stdout + version.stderr).trim();
|
||||
}
|
||||
await mkdir(join(root, "build"), { recursive: true });
|
||||
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "image.bin");
|
||||
const object = join(workspace, "image.o");
|
||||
const entrypoint = join(root, "scripts", "rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
TMP: runtime,
|
||||
TEMP: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await exec(
|
||||
ca65,
|
||||
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await exec(
|
||||
ld65,
|
||||
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const bytes = await readFile(path);
|
||||
// Independently known opcodes and link addresses from the source fixture.
|
||||
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await client.connect(transport);
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x008001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(opened.architecture, "6502");
|
||||
assert.equal(opened.entry_point, "0x8001");
|
||||
const status = await call("binary_session");
|
||||
const image = evidence(await call("inspect_native_load_image"), sha256);
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image.normalized_result),
|
||||
);
|
||||
assert.equal(image.normalized_result.architecture, "6502");
|
||||
assert.equal(image.normalized_result.base_address, "0x8001");
|
||||
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
|
||||
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
||||
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
||||
const memory = evidence(
|
||||
await call("read_bytes", { address: "0x8001", length: bytes.length }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = evidence(await call("list_procedures"), sha256);
|
||||
for (const address of ["0x8001", "0x8010"])
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
(procedure) => procedure.address === address,
|
||||
),
|
||||
);
|
||||
const instruction = evidence(
|
||||
await call("inspect_native_instruction", { address: "0x8003" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
instruction.normalized_result.references.some(
|
||||
(reference) => reference.call && reference.target_address === "0x8010",
|
||||
),
|
||||
);
|
||||
const pseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x8010" }),
|
||||
sha256,
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
|
||||
const filePseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
||||
assert.ok(
|
||||
filePseudo.locations.some(
|
||||
(location) => location.kind === "file-offset" && location.offset === 15,
|
||||
),
|
||||
);
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0xffff",
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
|
||||
const preserved = await call("binary_session");
|
||||
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
||||
assert.equal(preserved.entry_point, "0x8001");
|
||||
await call("close_binary");
|
||||
await client.ping();
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = parseEvidence(JSON.parse(stdout));
|
||||
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
||||
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
||||
assert.equal(cli.subject.digest.sha256, sha256);
|
||||
const fileCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
path,
|
||||
"--file-offset=0xf",
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
|
||||
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
||||
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
||||
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
const lowMemory = [];
|
||||
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
|
||||
// that overlapping zero-page/stack definitions never overlay the selected image.
|
||||
for (const [entry, fullAddressSpace] of [
|
||||
[0xff, false],
|
||||
[0, true],
|
||||
]) {
|
||||
const lowPath = join(workspace, `low-${entry}.bin`);
|
||||
await exec(
|
||||
ld65,
|
||||
[
|
||||
"-C",
|
||||
join(root, "tests/conformance/ghidra/6502.cfg"),
|
||||
"--start-addr",
|
||||
String(entry),
|
||||
"-o",
|
||||
lowPath,
|
||||
object,
|
||||
],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const linked = await readFile(lowPath);
|
||||
const expectedCode = Buffer.from(
|
||||
"a907200000c90ad003a92a60a9006018690360",
|
||||
"hex",
|
||||
);
|
||||
expectedCode.writeUInt16LE(entry + 15, 3);
|
||||
assert.deepEqual(linked, expectedCode);
|
||||
const lowBytes = fullAddressSpace
|
||||
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
|
||||
: linked;
|
||||
await writeFile(lowPath, lowBytes);
|
||||
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
|
||||
const lowEntry = `0x${entry.toString(16)}`;
|
||||
const lowOpened = await call("open_binary", {
|
||||
path: lowPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: lowEntry,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(lowOpened.entry_point, lowEntry);
|
||||
const lowImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(
|
||||
lowImage.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(lowImage.normalized_result),
|
||||
);
|
||||
assert.equal(lowImage.normalized_result.base_address, lowEntry);
|
||||
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
|
||||
const fileMappings =
|
||||
lowImage.normalized_result.observations.mappings.filter(
|
||||
(mapping) => mapping.source_file_index !== null,
|
||||
);
|
||||
assert.equal(fileMappings.length, 1);
|
||||
assert.equal(
|
||||
fileMappings[0].address_space,
|
||||
lowImage.normalized_result.observations.default_address_space,
|
||||
);
|
||||
assert.equal(fileMappings[0].overlay, false);
|
||||
assert.equal(fileMappings[0].start, lowEntry);
|
||||
assert.equal(fileMappings[0].length, lowBytes.length);
|
||||
const lowRead = evidence(
|
||||
await call("read_bytes", { address: lowEntry, length: linked.length }),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
|
||||
const lowCall = evidence(
|
||||
await call("inspect_native_instruction", {
|
||||
address: `0x${(entry + 2).toString(16)}`,
|
||||
}),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
lowCall.normalized_result.references.some(
|
||||
(reference) =>
|
||||
reference.call &&
|
||||
reference.target_address === `0x${(entry + 15).toString(16)}`,
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
const lowCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
lowPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
lowEntry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
|
||||
assert.deepEqual(
|
||||
lowCliEvidence.normalized_result,
|
||||
lowImage.normalized_result,
|
||||
);
|
||||
assert.equal(
|
||||
lowCliEvidence.analysis_profile.digest,
|
||||
lowImage.analysis_profile.digest,
|
||||
);
|
||||
assert.deepEqual(await readFile(lowPath), lowBytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
lowMemory.push({
|
||||
entry: lowEntry,
|
||||
bytes: lowBytes.length,
|
||||
sha256: lowSha256,
|
||||
});
|
||||
}
|
||||
let windowsReparseAdmission = "not-applicable";
|
||||
if (process.platform === "win32") {
|
||||
const junction = join(workspace, "linked");
|
||||
await symlink(workspace, junction, "junction");
|
||||
try {
|
||||
const linkedPath = join(junction, "image.bin");
|
||||
// Reopening the same bytes through a junction must replace the direct session.
|
||||
await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
const directImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(directImage.normalized_result.status, "verified");
|
||||
// Canonical file identity must not bypass admission of the new path.
|
||||
await call("open_binary", {
|
||||
path: linkedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
// open_binary selects a lazy session; the first analysis admits the snapshot.
|
||||
const linkedImage = await client.callTool(
|
||||
{ name: "inspect_native_load_image", arguments: {} },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.equal(linkedImage.isError, true);
|
||||
assert.match(
|
||||
JSON.stringify(linkedImage),
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
await call("close_binary");
|
||||
const snapshotPath = join(workspace, "direct-snapshot.json");
|
||||
const cliArgs = (selectedPath) => [
|
||||
entrypoint,
|
||||
"analyze",
|
||||
selectedPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--snapshot",
|
||||
snapshotPath,
|
||||
"--json",
|
||||
];
|
||||
const saved = await exec(process.execPath, cliArgs(path), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(
|
||||
evidence(JSON.parse(saved.stdout), sha256).operation,
|
||||
"binary_overview",
|
||||
);
|
||||
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
assert.ok(
|
||||
savedSnapshot.workflow_entries.some(
|
||||
({ operation }) => operation === "binary_overview",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
// Replaying a matching snapshot must still reach native path admission.
|
||||
await assert.rejects(
|
||||
exec(process.execPath, cliArgs(linkedPath), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
(cause) => {
|
||||
assert.match(
|
||||
cause.stdout + cause.stderr,
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
|
||||
} finally {
|
||||
await unlink(junction);
|
||||
}
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-6502",
|
||||
host: process.platform,
|
||||
toolchain,
|
||||
provider: image.provider,
|
||||
target: {
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
architecture: "6502",
|
||||
entry: "0x8001",
|
||||
},
|
||||
cli_mcp_load_image_parity: true,
|
||||
instructions_and_call_reference: true,
|
||||
decompilation: true,
|
||||
file_offset_decompilation_cli_mcp: true,
|
||||
invalid_mapping_preserves_session: true,
|
||||
low_memory_cli_mcp_parity: lowMemory,
|
||||
windows_reparse_admission: windowsReparseAdmission,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
}
|
||||
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
||||
return requireMcpOperationResult(result, name);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
function evidence(value, sha256) {
|
||||
const result = parseEvidence(value);
|
||||
assert.equal(result.subject.digest.sha256, sha256);
|
||||
assert.equal(result.subject.architecture, "6502");
|
||||
assert.equal(result.subject.format, "raw-binary");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -76,6 +76,8 @@ export const parseBinaryTarget = async (
|
|||
await access(candidate, constants.R_OK);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const canonical = await realpath(candidate);
|
||||
// Windows native admission must inspect the caller-selected reparse components.
|
||||
const sourcePath = process.platform === "win32" ? candidate : canonical;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const resolved = await resolveAppBundle(canonical);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
|
|
@ -113,7 +115,7 @@ export const parseBinaryTarget = async (
|
|||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
format: "raw-binary",
|
||||
|
|
@ -152,7 +154,7 @@ export const parseBinaryTarget = async (
|
|||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
format: "dos-com",
|
||||
|
|
@ -166,7 +168,7 @@ export const parseBinaryTarget = async (
|
|||
)
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
|
|
@ -175,7 +177,7 @@ export const parseBinaryTarget = async (
|
|||
if (artifactFormat !== undefined) {
|
||||
const identity = {
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
};
|
||||
return isArchiveFormat(artifactFormat)
|
||||
|
|
@ -195,7 +197,7 @@ export const parseBinaryTarget = async (
|
|||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
...bundle,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
|
|||
|
||||
/** Native contracts defined only for an active Mach-O executable. */
|
||||
const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"inspect_signature",
|
||||
"inspect_macho",
|
||||
"list_architectures",
|
||||
"observe_native_calls",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js";
|
||||
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
|
||||
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
|
|
@ -254,9 +255,14 @@ const runAnalysis = async (
|
|||
const route = preview.value.route;
|
||||
const bindingProfile = route.profile ?? undefined;
|
||||
const evidenceProfile = analysisProfileForRoute(route, tool);
|
||||
// A parsed Windows path does not prove Ghidra's native snapshot owner
|
||||
// admitted the selected source; CLI replay must wait for that client.
|
||||
if (
|
||||
bindingProfile !== undefined &&
|
||||
evidenceProfile !== undefined &&
|
||||
!requiresNativeSourceAdmissionBeforeReplay(
|
||||
route.binding?.identity.id,
|
||||
) &&
|
||||
allowsSnapshotReplay(route, tool)
|
||||
) {
|
||||
const cached = snapshotEvidenceForQuery(snapshot, {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
|||
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.QODER_CONFIG_DIR || join(home, ".qoder");
|
||||
|
||||
const factoryDirectory = ({ home }: ClientPathContext): string =>
|
||||
join(home, ".factory");
|
||||
|
||||
/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
|
||||
const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
|
||||
const override = env.QWEN_HOME;
|
||||
|
|
@ -558,6 +561,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||
markerPath: qoderDirectory,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "droid",
|
||||
displayName: "Factory Droid",
|
||||
skillPath: (context: ClientPathContext) =>
|
||||
join(factoryDirectory(context), "skills"),
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(factoryDirectory(context), "mcp.json"),
|
||||
markerPath: factoryDirectory,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
name: "zcode",
|
||||
zcodeFallbackPath: ({ home }: ClientPathContext) =>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export const isSnapshotCacheable = (
|
|||
descriptor.effects.mayWriteFilesystem === false) &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
/** Windows Ghidra must admit the selected source through its native snapshot owner. */
|
||||
export const requiresNativeSourceAdmissionBeforeReplay = (
|
||||
providerId: string | undefined,
|
||||
): boolean => process.platform === "win32" && providerId === "ghidra";
|
||||
|
||||
/** Bounded in-memory cache for one immutable binary identity. */
|
||||
export class AnalysisSnapshotCache {
|
||||
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ interface SessionBinding {
|
|||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
nativeSourceAdmitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -323,6 +324,7 @@ export class BinarySession
|
|||
profile,
|
||||
route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
this.resetSnapshotInvalidation();
|
||||
|
|
@ -609,6 +611,7 @@ export class BinarySession
|
|||
active: this.#active,
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
|
||||
unboundOperationError: (operation, route) =>
|
||||
this.#providerRouter.unboundOperationError(operation, route),
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
|
|
@ -645,6 +648,14 @@ export class BinarySession
|
|||
},
|
||||
})
|
||||
: profiled;
|
||||
// A successful Ghidra operation proves this client's native source snapshot
|
||||
// was admitted; a composite health response and parsed path do not.
|
||||
if (
|
||||
execution.ok &&
|
||||
capability.provider.id === "ghidra" &&
|
||||
this.#active === active
|
||||
)
|
||||
this.#active.nativeSourceAdmitted = true;
|
||||
this.#observeRuntimeAvailability(name, execution);
|
||||
if (
|
||||
execution.ok &&
|
||||
|
|
@ -838,6 +849,7 @@ export class BinarySession
|
|||
profile: previous.profile,
|
||||
route: previous.route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
return ok(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import type {
|
|||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js";
|
||||
import {
|
||||
isSnapshotCacheable,
|
||||
requiresNativeSourceAdmissionBeforeReplay,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
||||
interface ActiveExecutionBinding {
|
||||
|
|
@ -35,6 +38,7 @@ interface PrepareSessionExecutionInput {
|
|||
readonly active: ActiveExecutionBinding | undefined;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly nativeSourceAdmitted: boolean;
|
||||
readonly unboundOperationError: (
|
||||
operation: AnalysisOperation,
|
||||
route: SessionProviderRoute,
|
||||
|
|
@ -55,6 +59,7 @@ export const prepareSessionExecution = (
|
|||
active,
|
||||
operation,
|
||||
parameters,
|
||||
nativeSourceAdmitted,
|
||||
unboundOperationError,
|
||||
lookupSnapshot,
|
||||
} = input;
|
||||
|
|
@ -79,9 +84,12 @@ export const prepareSessionExecution = (
|
|||
const cacheable =
|
||||
profile !== undefined &&
|
||||
isSnapshotCacheable(operation, capability, parameters);
|
||||
const cached = cacheable
|
||||
? lookupSnapshot(active.target, profile, operation, parameters)
|
||||
: undefined;
|
||||
const cached =
|
||||
cacheable &&
|
||||
(!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) ||
|
||||
nativeSourceAdmitted)
|
||||
? lookupSnapshot(active.target, profile, operation, parameters)
|
||||
: undefined;
|
||||
return ok({ active, capability, profile, cacheable, cached });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,7 @@ export const resolveSessionTarget = async (
|
|||
input: ResolveSessionTargetInput,
|
||||
): Promise<Result<ResolvedSessionOpen, AnalysisError>> => {
|
||||
const { router, current, target, options } = input;
|
||||
const sameTarget =
|
||||
current?.target.path === target.path &&
|
||||
snapshotMatchesTarget(snapshotTarget(current.target), target);
|
||||
const sameTarget = sameSessionTarget(current?.target, target);
|
||||
const resolvedRoute =
|
||||
sameTarget && options.providerId === undefined && current !== undefined
|
||||
? ({ ok: true, value: current.route } as const)
|
||||
|
|
@ -113,9 +111,7 @@ export const validateResolvedSessionTarget = (
|
|||
},
|
||||
): Result<ResolvedSessionOpen, AnalysisError> => {
|
||||
const { current, target, route, options, stagedSnapshotMatches } = input;
|
||||
const sameTarget =
|
||||
current?.target.path === target.path &&
|
||||
snapshotMatchesTarget(snapshotTarget(current.target), target);
|
||||
const sameTarget = sameSessionTarget(current?.target, target);
|
||||
const snapshotError = validateSnapshot({
|
||||
snapshot: options.snapshot,
|
||||
target,
|
||||
|
|
@ -127,6 +123,16 @@ export const validateResolvedSessionTarget = (
|
|||
return { ok: true, value: { target, route, sameTarget } };
|
||||
};
|
||||
|
||||
const sameSessionTarget = (
|
||||
current: BinaryTarget | undefined,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
current !== undefined &&
|
||||
current.path === target.path &&
|
||||
// Native admission is bound to the requested path, not just its realpath.
|
||||
(current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) &&
|
||||
snapshotMatchesTarget(snapshotTarget(current), target);
|
||||
|
||||
interface ValidateSnapshotInput {
|
||||
readonly snapshot: AnalysisSnapshot | undefined;
|
||||
readonly target: BinaryTarget;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
|||
import { artifactCapabilities } from "./ArtifactProviderMetadata.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { freezeJsonSnapshot } from "../domain/immutableJson.js";
|
||||
import { createArtifactInspection } from "../domain/artifactInspection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
|
|
@ -341,7 +342,11 @@ class ArtifactClient implements AnalysisClient {
|
|||
total: 1,
|
||||
message: "inventory substep started",
|
||||
});
|
||||
const inventory = await this.inventory(inventoryParameters, options);
|
||||
// The freshly parsed inventory is exclusively owned here. Sealing it lets
|
||||
// the nested Evidence share it instead of cloning and re-digesting it.
|
||||
const inventory = freezeJsonSnapshot(
|
||||
await this.inventory(inventoryParameters, options),
|
||||
);
|
||||
const subject = subjectFor(
|
||||
this.target.sourcePath ?? this.target.path,
|
||||
inventory.manifest,
|
||||
|
|
@ -355,7 +360,10 @@ class ArtifactClient implements AnalysisClient {
|
|||
// The nested inventory already names every occurrence's logical path.
|
||||
locations: [],
|
||||
});
|
||||
const result = createArtifactInspection(inventoryEvidence);
|
||||
// The returned execution and its Evidence then reuse this owned report.
|
||||
const result = freezeJsonSnapshot(
|
||||
createArtifactInspection(inventoryEvidence),
|
||||
);
|
||||
await options?.progress?.report({
|
||||
phase: "inspect_artifact.inventory",
|
||||
completed: 1,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type NativeCommandRunner,
|
||||
} from "../native/CommandRunner.js";
|
||||
import {
|
||||
parseLipoArchitectures,
|
||||
parseLipoOutput,
|
||||
type LipoArchitecture,
|
||||
} from "../native/parsers/lipo.js";
|
||||
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
|
||||
|
|
@ -114,7 +114,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
};
|
||||
await this.#verifySource(source);
|
||||
const fileSize = source.initial.size;
|
||||
const architectures = parseLipoArchitectures(captured.value.stdout);
|
||||
const { architectures } = parseLipoOutput(captured.value.stdout, this.path);
|
||||
const structural = await readStructuralSlices(source.handle, fileSize);
|
||||
await this.#verifySource(source);
|
||||
if (structural.status === "malformed")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||
const omittedArchives: Array<{ path: string; reason: string }> = [];
|
||||
let unreportedOmittedArchives = 0;
|
||||
const incompleteHierarchies = new Map<string, number>();
|
||||
const unresolvedConnections = new Map<string, number>();
|
||||
const prototypeKeyOmissions = new Map<string, number | null>();
|
||||
let omitted = 0;
|
||||
let attempted = 0;
|
||||
|
|
@ -136,6 +137,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||
: { value: nib.raw, omittedPrototypeKeys: 0 };
|
||||
if (nib !== null && nib.omitted > 0)
|
||||
incompleteHierarchies.set(entry.path, nib.omitted);
|
||||
if (nib !== null && nib.unresolvedConnections > 0)
|
||||
unresolvedConnections.set(entry.path, nib.unresolvedConnections);
|
||||
if (omittedPrototypeKeys !== 0)
|
||||
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
|
||||
const documentHash = createHash("sha256").update(bytes).digest("hex");
|
||||
|
|
@ -170,6 +173,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
|||
});
|
||||
return finalizeHierarchyCoverage(result, {
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
invalid,
|
||||
omittedArchives,
|
||||
|
|
@ -237,6 +241,7 @@ const finalizeHierarchyCoverage = (
|
|||
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
|
||||
counts: {
|
||||
incompleteHierarchies: ReadonlyMap<string, number>;
|
||||
unresolvedConnections: ReadonlyMap<string, number>;
|
||||
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
|
||||
invalid: readonly string[];
|
||||
omittedArchives: readonly { path: string; reason: string }[];
|
||||
|
|
@ -247,6 +252,7 @@ const finalizeHierarchyCoverage = (
|
|||
) => {
|
||||
const {
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
invalid,
|
||||
omittedArchives,
|
||||
|
|
@ -260,6 +266,13 @@ const finalizeHierarchyCoverage = (
|
|||
...result,
|
||||
documents: result.documents.map((document) => ({
|
||||
...document,
|
||||
// The dictionary parser only sees connectors that were projected, so the
|
||||
// omission recorded where an ownerless connector was dropped has to be
|
||||
// added here for this producer to report the same document count as the
|
||||
// keyed-archive producer for the same recognized connector.
|
||||
connection_count:
|
||||
document.connection_count +
|
||||
(unresolvedConnections.get(document.relative_path) ?? 0),
|
||||
hierarchy_complete:
|
||||
document.hierarchy_complete &&
|
||||
!incompleteHierarchies.has(document.relative_path),
|
||||
|
|
@ -268,15 +281,32 @@ const finalizeHierarchyCoverage = (
|
|||
...result.graph,
|
||||
coverage: [
|
||||
...result.graph.coverage.map((facet) => {
|
||||
const omitted = incompleteHierarchies.get(
|
||||
const omittedHierarchy = incompleteHierarchies.get(
|
||||
facet.facet.replace(/^hierarchy:/u, ""),
|
||||
);
|
||||
return facet.facet.startsWith("hierarchy:") && omitted !== undefined
|
||||
if (
|
||||
facet.facet.startsWith("hierarchy:") &&
|
||||
omittedHierarchy !== undefined
|
||||
)
|
||||
return {
|
||||
...facet,
|
||||
status: "partial" as const,
|
||||
reason: "serialized_view_hierarchy_incomplete",
|
||||
omitted: facet.omitted + omittedHierarchy,
|
||||
};
|
||||
const omittedConnection = unresolvedConnections.get(
|
||||
facet.facet.replace(/^connections:/u, ""),
|
||||
);
|
||||
return facet.facet.startsWith("connections:") &&
|
||||
omittedConnection !== undefined
|
||||
? {
|
||||
...facet,
|
||||
status: "partial" as const,
|
||||
reason: "serialized_view_hierarchy_incomplete",
|
||||
omitted: facet.omitted + omitted,
|
||||
// A connection dropped by the limit is recoverable by raising
|
||||
// it, so that cause keeps the reason the graph already named.
|
||||
// Only a facet with no other cause reports the omission.
|
||||
reason: facet.reason ?? "connection_unresolved",
|
||||
omitted: facet.omitted + omittedConnection,
|
||||
}
|
||||
: facet;
|
||||
}),
|
||||
|
|
@ -306,7 +336,8 @@ const finalizeHierarchyCoverage = (
|
|||
result.graph.truncated ||
|
||||
counts.omitted > 0 ||
|
||||
archiveDecodePartial ||
|
||||
incompleteHierarchies.size > 0,
|
||||
incompleteHierarchies.size > 0 ||
|
||||
unresolvedConnections.size > 0,
|
||||
},
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
|
|
@ -316,6 +347,11 @@ const finalizeHierarchyCoverage = (
|
|||
: [
|
||||
"Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.",
|
||||
]),
|
||||
...(unresolvedConnections.size === 0
|
||||
? []
|
||||
: [
|
||||
"Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.",
|
||||
]),
|
||||
...(invalid.length === 0
|
||||
? []
|
||||
: [
|
||||
|
|
@ -496,6 +532,10 @@ const projectNibArchive = (
|
|||
);
|
||||
const connections: Record<string, JsonValue[]> = {};
|
||||
const hierarchy: NibHierarchyNode[] = [];
|
||||
// A recognized connector whose owner reference cannot be resolved produces no
|
||||
// connection entry, so the omission has to be counted here: the projected
|
||||
// dictionary carries no trace of it for a downstream consumer to find.
|
||||
let unresolvedConnections = 0;
|
||||
const reference = (value: JsonValue | undefined): number | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
|
|
@ -512,7 +552,10 @@ const projectNibArchive = (
|
|||
const values = object.values;
|
||||
const connectionFields = interfaceBuilderConnectionFieldNames(className);
|
||||
const source = reference(values[connectionFields.source]);
|
||||
if (source === null) continue;
|
||||
if (source === null) {
|
||||
unresolvedConnections += 1;
|
||||
continue;
|
||||
}
|
||||
const destination = reference(values[connectionFields.destination]);
|
||||
const label = referencedString(values[connectionFields.label]);
|
||||
const type = className.includes("Outlet")
|
||||
|
|
@ -608,6 +651,7 @@ const projectNibArchive = (
|
|||
);
|
||||
return {
|
||||
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
|
||||
unresolvedConnections,
|
||||
raw: jsonValueSchema.parse({
|
||||
"com.apple.ibtool.document.objects": objects,
|
||||
"com.apple.ibtool.document.connections": connections,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface ExpectedFile {
|
|||
|
||||
interface ExpectedContainer {
|
||||
readonly hash_status: ArtifactOccurrence["hash_status"];
|
||||
readonly format: ArtifactOccurrence["artifact_format"];
|
||||
readonly inventory: JavaScriptArtifactContainer | null;
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +220,9 @@ const visitReader = async (
|
|||
"integrity",
|
||||
`Nested ASAR was not integrity-verified: ${path}`,
|
||||
);
|
||||
// Inventory expands an ASAR-named file only when its bytes are an
|
||||
// ASAR. One it recorded as ordinary bytes has no members to read.
|
||||
if (container.format !== "asar") continue;
|
||||
const inventory = container.inventory;
|
||||
if (inventory === null)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
|
@ -395,6 +399,7 @@ const expectedInventory = (
|
|||
if (isNestedAsar)
|
||||
containers.set(occurrence.logical_path, {
|
||||
hash_status: occurrence.hash_status,
|
||||
format: occurrence.artifact_format,
|
||||
inventory: null,
|
||||
});
|
||||
continue;
|
||||
|
|
@ -412,6 +417,7 @@ const expectedInventory = (
|
|||
if (isNestedAsar)
|
||||
containers.set(occurrence.logical_path, {
|
||||
hash_status: occurrence.hash_status,
|
||||
format: occurrence.artifact_format,
|
||||
inventory: {
|
||||
path: occurrence.logical_path,
|
||||
sha256: node.sha256,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureDom } from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const documentUrl = `${origin}/screens/app`;
|
||||
|
|
@ -50,8 +52,11 @@ const snapshot = (
|
|||
],
|
||||
});
|
||||
|
||||
const capture = (value: ReturnType<typeof snapshot>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const capture = (value: ReturnType<typeof snapshot>) => {
|
||||
const step = captureDom(value, new Set([origin]), input).next();
|
||||
if (!step.done) throw new Error("Unexpected SVG identity query");
|
||||
return step.value;
|
||||
};
|
||||
|
||||
describe("DOM metadata document base URLs", () => {
|
||||
it("resolves links, actions and agent hints against the captured document base", () => {
|
||||
|
|
@ -282,6 +287,7 @@ interface SvgSnapshotNode {
|
|||
readonly name: string;
|
||||
readonly parent: number;
|
||||
readonly attributes?: readonly (readonly [string, string])[];
|
||||
readonly svg?: boolean;
|
||||
}
|
||||
|
||||
const svgDocument = (
|
||||
|
|
@ -300,7 +306,13 @@ const svgDocument = (
|
|||
const nodeValue = [intern("")];
|
||||
const parentIndex = [-1];
|
||||
const attributes: number[][] = [[]];
|
||||
const backendNodeId = [1];
|
||||
const svgNodeKinds = new Map<string, boolean>();
|
||||
for (const element of elements) {
|
||||
const id = backendNodeId.length + 1;
|
||||
backendNodeId.push(id);
|
||||
if (element.svg !== undefined)
|
||||
svgNodeKinds.set(`${String(id)}:${element.name}`, element.svg);
|
||||
nodeType.push(1);
|
||||
nodeName.push(intern(element.name));
|
||||
nodeValue.push(intern(""));
|
||||
|
|
@ -316,14 +328,35 @@ const svgDocument = (
|
|||
{
|
||||
documentURL: 0,
|
||||
baseURL: 1,
|
||||
nodes: { nodeType, nodeName, nodeValue, parentIndex, attributes },
|
||||
nodes: {
|
||||
nodeType,
|
||||
nodeName,
|
||||
nodeValue,
|
||||
parentIndex,
|
||||
attributes,
|
||||
backendNodeId,
|
||||
},
|
||||
svgNodeKinds,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) => {
|
||||
const kinds = new Map(
|
||||
value.documents.flatMap((document) => [...document.svgNodeKinds]),
|
||||
);
|
||||
const capture = captureDom(value, new Set([origin]), input);
|
||||
let step = capture.next();
|
||||
while (!step.done) {
|
||||
step = capture.next(
|
||||
step.value.map((query) =>
|
||||
kinds.get(`${String(query.backendNodeId)}:${query.nodeName}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const urlFor = (
|
||||
result: ReturnType<typeof captureSvg>,
|
||||
|
|
@ -331,6 +364,136 @@ const urlFor = (
|
|||
): (typeof result.urls)[number] | undefined =>
|
||||
result.urls.find((url) => url.node_index === nodeIndex);
|
||||
|
||||
describe("browser SVG identity", () => {
|
||||
const query = { backendNodeId: 42, nodeName: "a" };
|
||||
const node = { ...query, nodeType: 1 };
|
||||
|
||||
it("uses only a matching described element's SVG flag", () => {
|
||||
expect(capturedSvgNodeKind(query, { node: { ...node, isSVG: true } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
capturedSvgNodeKind(query, { node: { ...node, isSVG: false } }),
|
||||
).toBe(false);
|
||||
expect(capturedSvgNodeKind(query, { node })).toBe(false);
|
||||
for (const invalid of [
|
||||
{},
|
||||
{ node: { ...node, backendNodeId: 43, isSVG: true } },
|
||||
{ node: { ...node, nodeName: "A", isSVG: true } },
|
||||
{ node: { ...node, nodeType: 3, isSVG: true } },
|
||||
{ node: { ...node, isSVG: "true" } },
|
||||
])
|
||||
expect(capturedSvgNodeKind(query, invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("distinguishes identically named SVG and MathML elements regardless of parent", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "desc", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/math"]],
|
||||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/html"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/orphan"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls.map(({ url }) => url)).toEqual([
|
||||
`${origin}/svg`,
|
||||
`${origin}/orphan`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("records unresolved identity without inventing a destination or hiding attributes", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/unknown"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(value, new Set([origin]), input, completeness);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [{ backendNodeId: 2, nodeName: "a" }],
|
||||
});
|
||||
const step = capture.next([undefined]);
|
||||
expect(step.done).toBe(true);
|
||||
if (!step.done) throw new Error("Unexpected second query");
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(step.value.urls).toEqual([]);
|
||||
expect(step.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]);
|
||||
expect(completeness.snapshot().excluded).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps batched SVG identities aligned around missing IDs and ordinary links", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/svg"]] },
|
||||
{ name: "A", parent: 0, attributes: [["href", "/ordinary"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/missing-id"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/non-svg"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(
|
||||
{
|
||||
...value,
|
||||
documents: value.documents.map((document) => ({
|
||||
...document,
|
||||
nodes: { ...document.nodes, backendNodeId: [1, 2, 3, null, 5] },
|
||||
})),
|
||||
},
|
||||
new Set([origin]),
|
||||
input,
|
||||
completeness,
|
||||
);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [
|
||||
{ backendNodeId: 2, nodeName: "a" },
|
||||
{ backendNodeId: 5, nodeName: "a" },
|
||||
],
|
||||
});
|
||||
const step = capture.next([true, false]);
|
||||
if (!step.done) throw new Error("Unexpected second batch");
|
||||
expect(
|
||||
step.value.urls.map(({ node_index, url }) => [node_index, url]),
|
||||
).toEqual([
|
||||
[1, `${origin}/svg`],
|
||||
[2, `${origin}/ordinary`],
|
||||
]);
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(completeness.snapshot().excluded).toContainEqual({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG xlink:href destinations", () => {
|
||||
it("reports an SVG anchor that only has xlink:href, keeping the first value", () => {
|
||||
const result = captureSvg(
|
||||
|
|
@ -339,6 +502,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "g", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/xl-s2h"],
|
||||
|
|
@ -371,6 +535,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
[first, valueFor(first)],
|
||||
|
|
@ -395,18 +560,21 @@ describe("SVG xlink:href destinations", () => {
|
|||
svgDocument([
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/xl-html"]],
|
||||
},
|
||||
{ name: "math", parent: 0 },
|
||||
{
|
||||
name: "mrow",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-math"]],
|
||||
},
|
||||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-s2h"]],
|
||||
},
|
||||
|
|
@ -425,6 +593,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "https://cdn.example.test/out"]],
|
||||
},
|
||||
|
|
@ -444,8 +613,18 @@ describe("SVG xlink:href destinations", () => {
|
|||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "image", parent: 1, attributes: [["xlink:href", "guide"]] },
|
||||
{ name: "use", parent: 1, attributes: [["xlink:href", "icon.svg"]] },
|
||||
{
|
||||
name: "image",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "guide"]],
|
||||
},
|
||||
{
|
||||
name: "use",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "icon.svg"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([
|
||||
|
|
@ -466,14 +645,24 @@ describe("SVG xlink:href destinations", () => {
|
|||
});
|
||||
|
||||
describe("SVG xlink:href context", () => {
|
||||
it("keeps forward ancestor context local to each document", () => {
|
||||
it("binds SVG identity to the selected element across documents", () => {
|
||||
const svg = svgDocument([
|
||||
{ name: "a", parent: 2, attributes: [["xlink:href", "/svg-link"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg-link"]],
|
||||
},
|
||||
{ name: "g", parent: 3 },
|
||||
{ name: "svg", parent: 0 },
|
||||
]);
|
||||
const html = svgDocument([
|
||||
{ name: "A", parent: 2, attributes: [["xlink:href", "/html-link"]] },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/html-link"]],
|
||||
},
|
||||
{ name: "DIV", parent: 3 },
|
||||
{ name: "BODY", parent: 0 },
|
||||
]);
|
||||
|
|
@ -507,32 +696,39 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("stops SVG context at foreignObject and resumes it for a nested svg", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "foreignObject", parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
});
|
||||
it.each(["foreignObject", "desc", "title"])(
|
||||
"preserves browser SVG identity across %s HTML integration points",
|
||||
(integrationPoint) => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: integrationPoint, parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("SVG XLink precedence and unknown identity", () => {
|
||||
it("keeps an empty href ahead of xlink:href", () => {
|
||||
const baseUrl = `${origin}/screens/assets/`;
|
||||
const result = captureSvg(
|
||||
|
|
@ -541,6 +737,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
["href", ""],
|
||||
|
|
@ -567,6 +764,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "javascript:void(0)"]],
|
||||
},
|
||||
|
|
@ -582,7 +780,7 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("does not treat a cyclic or dangling parent chain as SVG", () => {
|
||||
it("keeps an unavailable SVG identity unknown despite ancestor names", () => {
|
||||
const cyclic = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "g", parent: 2 },
|
||||
|
|
@ -652,7 +850,12 @@ describe("XML SVG roots and namespace scope restoration", () => {
|
|||
["xmlns:p", XLINK_NAMESPACE],
|
||||
],
|
||||
},
|
||||
{ name: "a", parent: 1, attributes: [["p:href", "/linked"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["p:href", "/linked"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([approvedHref(2, `${origin}/linked`)]);
|
||||
|
|
@ -764,6 +967,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
{ name: "svg:foreignObject", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 3,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
|
|
@ -799,6 +1003,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/not-xlink"],
|
||||
|
|
@ -918,11 +1123,13 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-literal"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["x:href", "/xl-custom-html"]],
|
||||
},
|
||||
|
|
@ -937,6 +1144,7 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/xl-html-anchor"]],
|
||||
},
|
||||
|
|
@ -944,7 +1152,9 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
);
|
||||
expect(result.urls).toEqual([approvedHref(3, `${origin}/xl-literal`)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XML namespace resolution ordering and capacity", () => {
|
||||
it("applies an element's own xmlns declarations to its name and href", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
} from "./CdpCaptureValues.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
createDocumentSvgAncestry,
|
||||
domSvgLink,
|
||||
type DomSvgNodeQuery,
|
||||
} from "./CdpCaptureDomSvg.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
|
|
@ -135,18 +139,39 @@ export const walkFrameTrees = function* (
|
|||
}
|
||||
};
|
||||
|
||||
export const captureDom = (
|
||||
interface DomMetadataCapture {
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
readonly unresolvedSvgNodes: number;
|
||||
}
|
||||
|
||||
export interface DomCapture extends DomMetadataCapture {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
}
|
||||
|
||||
// Bound retained projections and outstanding CDP reads independently of page size.
|
||||
// Eight leaves room for the session's other cancellation listeners.
|
||||
const DOM_METADATA_BATCH_SIZE = 8;
|
||||
|
||||
interface PendingDomMetadata {
|
||||
readonly options: DomMetadataOptions;
|
||||
// Undefined means no query is needed; null means the candidate has no valid ID.
|
||||
readonly svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
}
|
||||
|
||||
/** Project one snapshot, requesting bounded batches of SVG identity facts. */
|
||||
export function* captureDom(
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
input: InspectWebPageInput,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
} => {
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
||||
|
|
@ -156,6 +181,7 @@ export const captureDom = (
|
|||
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
||||
let excludedUrls = 0;
|
||||
let total = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
|
|
@ -172,6 +198,9 @@ export const captureDom = (
|
|||
const attributes = Array.isArray(documentNodes.attributes)
|
||||
? documentNodes.attributes
|
||||
: [];
|
||||
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
|
||||
? documentNodes.backendNodeId
|
||||
: [];
|
||||
const baseIndex = nodes.length;
|
||||
const ancestry = createDocumentSvgAncestry({
|
||||
nodeTypes,
|
||||
|
|
@ -181,12 +210,29 @@ export const captureDom = (
|
|||
attributes,
|
||||
});
|
||||
total += nodeTypes.length;
|
||||
const pendingMetadata: PendingDomMetadata[] = [];
|
||||
for (let index = 0; index < nodeTypes.length; index += 1) {
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]);
|
||||
const attributeNames = uniqueAttributeNames(attributeIndexes, strings);
|
||||
const svgLink = domSvgLink(ancestry, index);
|
||||
let svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
if (
|
||||
!svgLink.xmlDocument &&
|
||||
nodeTypes[index] === 1 &&
|
||||
attributeNames.includes("xlink:href") &&
|
||||
!attributeNames.includes("href")
|
||||
) {
|
||||
const backendNodeId = numberValue(backendNodeIds[index]);
|
||||
svgQuery =
|
||||
backendNodeId !== undefined &&
|
||||
Number.isSafeInteger(backendNodeId) &&
|
||||
backendNodeId > 0
|
||||
? { backendNodeId, nodeName }
|
||||
: null;
|
||||
}
|
||||
nodes.push({
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
|
|
@ -195,30 +241,34 @@ export const captureDom = (
|
|||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
attribute_names: attributeNames,
|
||||
});
|
||||
const metadata = domMetadata({
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...domSvgLink(ancestry, index),
|
||||
pendingMetadata.push({
|
||||
svgQuery,
|
||||
options: {
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...svgLink,
|
||||
},
|
||||
});
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
if (
|
||||
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
|
||||
index === nodeTypes.length - 1
|
||||
) {
|
||||
const metadata = yield* captureDomMetadata(
|
||||
pendingMetadata,
|
||||
completeness,
|
||||
);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -227,8 +277,54 @@ export const captureDom = (
|
|||
urls,
|
||||
agentHints,
|
||||
excludedUrls,
|
||||
unresolvedSvgNodes,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function* captureDomMetadata(
|
||||
pending: readonly PendingDomMetadata[],
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomMetadataCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const queries = pending.flatMap(({ svgQuery }) =>
|
||||
svgQuery == null ? [] : [svgQuery],
|
||||
);
|
||||
const kinds = queries.length > 0 ? yield queries : [];
|
||||
const urls: DomMetadataCapture["urls"] = [];
|
||||
const agentHints: DomMetadataCapture["agentHints"] = [];
|
||||
let queryIndex = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
let excludedUrls = 0;
|
||||
for (const { options, svgQuery } of pending) {
|
||||
let svgElement = options.svgElement;
|
||||
if (svgQuery !== undefined) {
|
||||
const kind = svgQuery === null ? undefined : kinds[queryIndex++];
|
||||
svgElement = kind === true;
|
||||
if (kind === undefined) {
|
||||
unresolvedSvgNodes += 1;
|
||||
completeness?.exclude("metadata", "provider_unavailable");
|
||||
}
|
||||
}
|
||||
const metadata = domMetadata({ ...options, svgElement });
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
|
||||
}
|
||||
|
||||
export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { numberValue } from "./CdpCaptureValues.js";
|
||||
import { numberValue, recordValue } from "./CdpCaptureValues.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
|
|
@ -18,11 +18,34 @@ interface XmlSvgLink {
|
|||
|
||||
interface DocumentSvgAncestry {
|
||||
readonly xml: boolean;
|
||||
readonly nodes: DomSnapshotNodes;
|
||||
readonly svgContexts: Map<number, boolean>;
|
||||
readonly xmlLinks: Map<number, XmlSvgLink>;
|
||||
}
|
||||
|
||||
export interface DomSvgNodeQuery {
|
||||
readonly backendNodeId: number;
|
||||
readonly nodeName: string;
|
||||
}
|
||||
|
||||
/** Bind the browser's SVG flag to the exact snapshotted element. */
|
||||
export const capturedSvgNodeKind = (
|
||||
query: DomSvgNodeQuery,
|
||||
result: unknown,
|
||||
): boolean | undefined => {
|
||||
const node = recordValue(recordValue(result)?.node);
|
||||
if (
|
||||
node?.backendNodeId !== query.backendNodeId ||
|
||||
node.nodeName !== query.nodeName ||
|
||||
node.nodeType !== 1
|
||||
)
|
||||
return undefined;
|
||||
// CDP omits isSVG for non-SVG nodes. Other values are malformed, not false.
|
||||
return node.isSVG === undefined
|
||||
? false
|
||||
: typeof node.isSVG === "boolean"
|
||||
? node.isSVG
|
||||
: undefined;
|
||||
};
|
||||
|
||||
interface DomSvgLink {
|
||||
readonly svgElement: boolean;
|
||||
readonly xmlDocument: boolean;
|
||||
|
|
@ -79,8 +102,6 @@ export const createDocumentSvgAncestry = (
|
|||
const xml = xmlDocument(nodes);
|
||||
return {
|
||||
xml,
|
||||
nodes,
|
||||
svgContexts: new Map(),
|
||||
xmlLinks: xml ? resolveXmlElements(nodes) : new Map(),
|
||||
};
|
||||
};
|
||||
|
|
@ -91,11 +112,7 @@ export const domSvgLink = (
|
|||
): DomSvgLink => {
|
||||
if (!ancestry.xml) {
|
||||
return {
|
||||
svgElement: elementInSvgContext(
|
||||
index,
|
||||
ancestry.nodes,
|
||||
ancestry.svgContexts,
|
||||
),
|
||||
svgElement: false,
|
||||
xmlDocument: false,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
|
|
@ -108,43 +125,6 @@ export const domSvgLink = (
|
|||
};
|
||||
};
|
||||
|
||||
// Chrome's HTML parser records SVG local names (`svg`, `foreignObject`).
|
||||
// Descendants of foreignObject are HTML until a nested `svg`.
|
||||
const elementInSvgContext = (
|
||||
index: number,
|
||||
nodes: DomSnapshotNodes,
|
||||
contexts: Map<number, boolean>,
|
||||
): boolean => {
|
||||
let current = index;
|
||||
const seen = new Set<number>();
|
||||
let inSvg = false;
|
||||
while (
|
||||
current >= 0 &&
|
||||
current < nodes.nodeNames.length &&
|
||||
!seen.has(current)
|
||||
) {
|
||||
const cached = contexts.get(current);
|
||||
if (cached !== undefined) {
|
||||
inSvg = cached;
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[current]) === "svg") {
|
||||
inSvg = true;
|
||||
break;
|
||||
}
|
||||
const parent = Math.trunc(nodes.parents[current] ?? -1);
|
||||
if (parent < 0 || parent === current) break;
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[parent]) === "foreignObject")
|
||||
break;
|
||||
current = parent;
|
||||
}
|
||||
// Cache the entire examined chain, including forward references and cycles,
|
||||
// so shared ancestors are examined once per document.
|
||||
for (const node of seen) contexts.set(node, inSvg);
|
||||
return inSvg;
|
||||
};
|
||||
|
||||
interface NamespaceRestore {
|
||||
readonly prefix: string;
|
||||
readonly previous: string | undefined;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import {
|
|||
captureFrames,
|
||||
captureResources,
|
||||
type CapturedResource,
|
||||
type DomCapture,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
|
|
@ -52,7 +54,7 @@ interface CaptureState {
|
|||
interface CapturedSections {
|
||||
readonly attachedUrl: string;
|
||||
readonly frames: WebPageInspection["frames"];
|
||||
readonly dom: ReturnType<typeof captureDom>;
|
||||
readonly dom: DomCapture;
|
||||
readonly accessibility: ReturnType<typeof captureAccessibility>;
|
||||
readonly scripts: WebPageInspection["scripts"];
|
||||
readonly resources: WebPageInspection["resources"];
|
||||
|
|
@ -260,9 +262,7 @@ const authorizeObservationWindow = async (
|
|||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const capturePageDom = async (
|
||||
state: CaptureState,
|
||||
): Promise<ReturnType<typeof captureDom>> => {
|
||||
const capturePageDom = async (state: CaptureState): Promise<DomCapture> => {
|
||||
const { context, allowedOrigins } = state;
|
||||
const result = await context.connection.send(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
|
|
@ -274,12 +274,56 @@ const capturePageDom = async (
|
|||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
return captureDom(
|
||||
const capture = captureDom(
|
||||
result,
|
||||
allowedOrigins,
|
||||
context.input,
|
||||
state.events.completeness,
|
||||
);
|
||||
let step = capture.next();
|
||||
let descriptionUnavailable = false;
|
||||
const pendingController = new AbortController();
|
||||
const signal =
|
||||
context.signal === undefined
|
||||
? pendingController.signal
|
||||
: AbortSignal.any([context.signal, pendingController.signal]);
|
||||
while (!step.done) {
|
||||
const pending = step.value.map(async (query) => {
|
||||
if (descriptionUnavailable) return undefined;
|
||||
try {
|
||||
return capturedSvgNodeKind(
|
||||
query,
|
||||
await context.connection.send(
|
||||
"DOM.describeNode",
|
||||
{ backendNodeId: query.backendNodeId, depth: 0 },
|
||||
context.sessionId,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// A detached node can disappear between snapshot and description.
|
||||
// Preserve partial metadata, but propagate transport/cancellation errors.
|
||||
if (!(cause instanceof CdpCommandRejection)) throw cause;
|
||||
if (cause.code === -32_601) descriptionUnavailable = true;
|
||||
else if (cause.code !== -32_000) throw cause;
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Promise.all preserves snapshot order even when CDP replies out of order.
|
||||
step = capture.next(await Promise.all(pending));
|
||||
} catch (cause: unknown) {
|
||||
// Stop and settle this bounded batch before the session can be detached.
|
||||
pendingController.abort();
|
||||
await Promise.allSettled(pending);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
if (step.value.unresolvedSvgNodes > 0)
|
||||
state.limitations.push(
|
||||
`SVG identity was unavailable for ${String(step.value.unresolvedSvgNodes)} snapshotted XLink candidate nodes; their URL destinations remain unknown.`,
|
||||
);
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const normalizedInspection = (
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const NATIVE_TOOL_CONTRACTS = [
|
|||
),
|
||||
native(
|
||||
"inspect_signature",
|
||||
"Inspect the active artifact's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.",
|
||||
"Inspect the active Mach-O executable's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.",
|
||||
z.object({}),
|
||||
),
|
||||
native(
|
||||
|
|
|
|||
|
|
@ -149,12 +149,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
|||
),
|
||||
official(
|
||||
"procedure_callees",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty.",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_callers",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references.",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ import { z } from "zod";
|
|||
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"advertises the raw-only 6502 Evidence subject constraint in %s",
|
||||
(target) => {
|
||||
const valid = createEvidence(
|
||||
{
|
||||
path: "/fixture/image.bin",
|
||||
sha256: "a".repeat(64),
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
},
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "address_name", parameters: {}, result: "entry" },
|
||||
);
|
||||
const advertised = toolOutputSchemaWithMetadata(
|
||||
toolContract("address_name"),
|
||||
)["~standard"].jsonSchema.output({ target });
|
||||
const validate = new AjvJsonSchemaValidator().getValidator(advertised);
|
||||
expect(validate(valid).valid).toBe(true);
|
||||
expect(
|
||||
validate({
|
||||
...valid,
|
||||
subject: { ...valid.subject, format: "pe" },
|
||||
}).valid,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps dialect-specific validators distinct after JSON schema serialization", () => {
|
||||
const contract = {
|
||||
|
|
|
|||
|
|
@ -444,11 +444,11 @@ const lifecycleTargetFields = {
|
|||
path: z.string(),
|
||||
kind: targetKindSchema,
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
};
|
||||
const detectedLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
format: targetFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
|
||||
});
|
||||
const rawLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const snapshotWithHistoricalEvidence = () => {
|
|||
};
|
||||
return {
|
||||
current,
|
||||
snapshot: {
|
||||
snapshot: analysisSnapshotSchema.parse({
|
||||
target,
|
||||
binding,
|
||||
entries: [entry],
|
||||
|
|
@ -66,11 +66,32 @@ const snapshotWithHistoricalEvidence = () => {
|
|||
result: { summary: "unprofiled" },
|
||||
}),
|
||||
]),
|
||||
} satisfies AnalysisSnapshot,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("analysis snapshot target identity", () => {
|
||||
it("keeps 6502 confined to executable raw snapshots", () => {
|
||||
const target = {
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
kind: "executable",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "pe",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "raw-binary",
|
||||
entry_point: "0x8001",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("partitions raw snapshots and query identities by the canonical entry", () => {
|
||||
const target: BinaryTarget = {
|
||||
path: "/fixture/image.bin",
|
||||
|
|
@ -120,6 +141,48 @@ describe("analysis snapshot target identity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("analysis snapshot nested subject contract", () => {
|
||||
it("rejects raw-only 6502 in nested detected execution subjects", () => {
|
||||
const { snapshot } = snapshotWithHistoricalEvidence();
|
||||
const entry = snapshot.entries[0];
|
||||
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
|
||||
const malformedSubject = {
|
||||
...entry.execution.subject,
|
||||
format: "pe",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
analysis_profile: snapshot.binding.analysis_profile,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis snapshot contract", () => {
|
||||
it.each([
|
||||
["dos-mz", "x86"],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ import { digestSchema } from "./../domain/digests.js";
|
|||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
import { rawBinaryEntryPointSchema } from "./rawBinary.js";
|
||||
import {
|
||||
binaryArchitectureSchema,
|
||||
rawBinaryEntryPointSchema,
|
||||
} from "./rawBinary.js";
|
||||
import {
|
||||
relationshipCoverageSchema,
|
||||
type RelationshipCoverage,
|
||||
|
|
@ -73,20 +76,30 @@ const formatSchema = z.enum([
|
|||
"source-map",
|
||||
]);
|
||||
const kindSchema = z.enum(["executable", "database", "archive", "artifact"]);
|
||||
const subjectSchema = z.object({
|
||||
const subjectBaseSchema = z.object({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
format: z.enum([
|
||||
...formatSchema.options,
|
||||
"hopper",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]),
|
||||
architecture: architectureSchema.nullable(),
|
||||
});
|
||||
const subjectFormatSchema = z.enum([
|
||||
...formatSchema.options,
|
||||
"hopper",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]);
|
||||
// Snapshot entries retain the same raw-only architecture boundary as targets.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: architectureSchema.nullable(),
|
||||
}),
|
||||
]);
|
||||
const targetIdentitySchema = z.object({
|
||||
sha256: digestSchema,
|
||||
kind: kindSchema,
|
||||
|
|
@ -97,9 +110,12 @@ const targetSchema = z.discriminatedUnion("format", [
|
|||
format: z.enum(
|
||||
formatSchema.options.filter((format) => format !== "raw-binary"),
|
||||
),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
}),
|
||||
targetIdentitySchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
kind: z.literal("executable"),
|
||||
architecture: binaryArchitectureSchema,
|
||||
entry_point: rawBinaryEntryPointSchema,
|
||||
}),
|
||||
]);
|
||||
|
|
@ -332,18 +348,21 @@ const canonicalJson = (value: unknown): string => {
|
|||
};
|
||||
|
||||
/** Project a binary into the path-free identity used for cache invalidation. */
|
||||
export const snapshotTarget = (
|
||||
target: BinaryTarget,
|
||||
): AnalysisSnapshotTarget => {
|
||||
const identity = {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
};
|
||||
return target.format === "raw-binary"
|
||||
? { ...identity, format: target.format, entry_point: target.entryPoint }
|
||||
: { ...identity, format: target.format };
|
||||
};
|
||||
export const snapshotTarget = (target: BinaryTarget): AnalysisSnapshotTarget =>
|
||||
target.format === "raw-binary"
|
||||
? {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture,
|
||||
format: target.format,
|
||||
entry_point: target.entryPoint,
|
||||
}
|
||||
: {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
format: target.format,
|
||||
};
|
||||
|
||||
/** Build the immutable provider/profile binding used by a cached snapshot. */
|
||||
export const snapshotBinding = (
|
||||
|
|
@ -475,10 +494,10 @@ export const createAnalysisSnapshotWorkflowEntry = (input: {
|
|||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
: subjectSchema.parse({
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -538,10 +557,10 @@ export const createAnalysisSnapshotEntry = (input: {
|
|||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
: subjectSchema.parse({
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -510,6 +510,106 @@ describe("AppKit keyed archive connectors", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("keyed archive unresolved connectors", () => {
|
||||
// A rooted UIKit archive whose only connector is UID 3, with UID 1 as the
|
||||
// owner, UID 2 as the destination and UID 7 as the "view" label.
|
||||
const rootedOutletArchive = (connection: Record<string, unknown>) => ({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { CF$UID: 4 } },
|
||||
{ $class: { CF$UID: 5 } },
|
||||
{ $class: { CF$UID: 6 }, ...connection },
|
||||
{
|
||||
$classname: "UIViewController",
|
||||
$classes: ["UIViewController", "UIResponder", "NSObject"],
|
||||
},
|
||||
{ $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] },
|
||||
{
|
||||
$classname: "UIRuntimeOutletConnection",
|
||||
$classes: [
|
||||
"UIRuntimeOutletConnection",
|
||||
"UIRuntimeConnection",
|
||||
"NSObject",
|
||||
],
|
||||
},
|
||||
"view",
|
||||
{
|
||||
$class: { CF$UID: 11 },
|
||||
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }],
|
||||
},
|
||||
{ $class: { CF$UID: 11 }, "NS.objects": [{ CF$UID: 3 }] },
|
||||
{
|
||||
$class: { CF$UID: 11 },
|
||||
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }],
|
||||
},
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
],
|
||||
$top: {
|
||||
root: { CF$UID: 8 },
|
||||
UINibConnectionsKey: { CF$UID: 9 },
|
||||
UINibObjectsKey: { CF$UID: 10 },
|
||||
UINibTopLevelObjectsKey: { CF$UID: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
const project = (raw: unknown) =>
|
||||
buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.nib",
|
||||
archiveSha256: hash,
|
||||
documentKind: "nib",
|
||||
raw,
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
|
||||
it("covers a resolvable connector as a complete examined connection", () => {
|
||||
const raw = rootedOutletArchive({
|
||||
UISource: { CF$UID: 1 },
|
||||
UIDestination: { CF$UID: 2 },
|
||||
UILabel: { CF$UID: 7 },
|
||||
});
|
||||
expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(0);
|
||||
expect(project(raw).graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "connections:Main.nib",
|
||||
status: "complete",
|
||||
examined: 1,
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a recognized connector with no resolvable owner as unresolved", () => {
|
||||
const raw = rootedOutletArchive({
|
||||
UIDestination: { CF$UID: 2 },
|
||||
UILabel: { CF$UID: 7 },
|
||||
});
|
||||
expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(1);
|
||||
|
||||
const analysis = project(raw);
|
||||
expect(analysis.graph.nodes.some(({ kind }) => kind === "outlet")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(analysis.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "connections:Main.nib",
|
||||
status: "partial",
|
||||
reason: "connection_unresolved",
|
||||
examined: 0,
|
||||
omitted: 1,
|
||||
}),
|
||||
);
|
||||
expect(analysis.graph.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("names the archive once per evidence record", () => {
|
||||
const relativePath = "Views/Main.storyboardc/scene.nib/objects.nib";
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
|
|
|
|||
|
|
@ -410,18 +410,26 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
const connectionsTruncated =
|
||||
parserOmittedConnections > 0 ||
|
||||
parsed.connections.length > input.limits.max_connections;
|
||||
// A recognized connector the parser could not attach to an owner produces
|
||||
// no connection record, so its coverage is partial with its own reason
|
||||
// rather than absent or truncated.
|
||||
const connectionsUnresolved = parsed.unresolvedConnections > 0;
|
||||
omittedObjects +=
|
||||
parsed.omittedObjects +
|
||||
Math.max(0, parsed.objects.length - documentObjectCount);
|
||||
omittedConnections +=
|
||||
Math.max(0, parsed.connections.length - input.limits.max_connections) +
|
||||
parserOmittedConnections;
|
||||
parserOmittedConnections +
|
||||
parsed.unresolvedConnections;
|
||||
summaries.push({
|
||||
relative_path: document.relativePath,
|
||||
archive_sha256: document.archiveSha256,
|
||||
document_kind: document.documentKind,
|
||||
object_count: parsed.objectCount,
|
||||
connection_count: parsed.connections.length + parserOmittedConnections,
|
||||
connection_count:
|
||||
parsed.connections.length +
|
||||
parserOmittedConnections +
|
||||
parsed.unresolvedConnections,
|
||||
hierarchy_complete:
|
||||
parsed.hierarchy.length > 0 &&
|
||||
!truncated &&
|
||||
|
|
@ -440,13 +448,17 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
{
|
||||
facet: `connections:${document.relativePath}`,
|
||||
status:
|
||||
connectionsTruncated || documentOmittedConnections > 0
|
||||
connectionsTruncated ||
|
||||
documentOmittedConnections > 0 ||
|
||||
connectionsUnresolved
|
||||
? "partial"
|
||||
: "complete",
|
||||
reason:
|
||||
connectionsTruncated || documentOmittedConnections > 0
|
||||
? "connection_limit_reached"
|
||||
: null,
|
||||
: connectionsUnresolved
|
||||
? "connection_unresolved"
|
||||
: null,
|
||||
examined: Math.min(
|
||||
parsed.connections.length,
|
||||
input.limits.max_connections,
|
||||
|
|
@ -457,7 +469,8 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
|||
parsed.connections.length - input.limits.max_connections,
|
||||
) +
|
||||
documentOmittedConnections +
|
||||
parserOmittedConnections,
|
||||
parserOmittedConnections +
|
||||
parsed.unresolvedConnections,
|
||||
},
|
||||
{
|
||||
facet: `hierarchy:${document.relativePath}`,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export const parseInterfaceBuilderRecords = (
|
|||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
readonly unresolvedConnections: number;
|
||||
readonly omittedHierarchyReferences: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
|
|
@ -135,6 +136,7 @@ export const parseInterfaceBuilderRecords = (
|
|||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
unresolvedConnections: 0,
|
||||
omittedHierarchyReferences: 0,
|
||||
};
|
||||
};
|
||||
|
|
@ -320,6 +322,7 @@ const parseKeyedArchive = (
|
|||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
let unresolvedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
|
|
@ -371,7 +374,13 @@ const parseKeyedArchive = (
|
|||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
if (source === null) {
|
||||
// A recognized connector that cannot name its owner is an unresolved
|
||||
// connection, not an absent one, and cannot be represented as a
|
||||
// connection because connection identity requires a source.
|
||||
unresolvedConnections += 1;
|
||||
continue;
|
||||
}
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
|
|
@ -426,6 +435,7 @@ const parseKeyedArchive = (
|
|||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
unresolvedConnections,
|
||||
omittedHierarchyReferences: hierarchy.omittedReferences,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -351,7 +351,9 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
|
||||
const elfArchitecture = (
|
||||
machine: number,
|
||||
): Exclude<BinaryArchitecture, "6502"> | undefined => {
|
||||
switch (machine) {
|
||||
case 3:
|
||||
return "x86";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { ZipPackageFormat } from "./zipPackageFormat.js";
|
||||
|
||||
/** Provider-neutral CPU families detected from supported executable headers. */
|
||||
/** Provider-neutral CPU families detected from headers or explicitly selected raw loads. */
|
||||
export const BINARY_ARCHITECTURES = [
|
||||
"x86",
|
||||
"x86_64",
|
||||
"arm",
|
||||
"arm64",
|
||||
"mips",
|
||||
"6502",
|
||||
"riscv",
|
||||
] as const;
|
||||
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
|
||||
|
|
@ -40,7 +41,7 @@ export const isElfMetadataArchitecture = (
|
|||
/** Recognized family with no ELF metadata member; the header alone is the fact. */
|
||||
export type HeaderArchitecture = Exclude<
|
||||
BinaryArchitecture,
|
||||
ElfMetadataArchitecture
|
||||
ElfMetadataArchitecture | "6502"
|
||||
>;
|
||||
|
||||
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
|
||||
|
|
@ -144,8 +145,17 @@ export type BinaryTarget =
|
|||
readonly executableRole?: never;
|
||||
readonly managed?: never;
|
||||
})
|
||||
| (ExecutableTarget & {
|
||||
| (BinaryTargetIdentity & {
|
||||
readonly kind: "executable";
|
||||
readonly format: "raw-binary";
|
||||
readonly architecture: Exclude<
|
||||
BinaryArchitecture,
|
||||
ElfMetadataArchitecture
|
||||
>;
|
||||
readonly availableArchitectures: readonly Exclude<
|
||||
BinaryArchitecture,
|
||||
ElfMetadataArchitecture
|
||||
>[];
|
||||
readonly entryPoint: string;
|
||||
readonly entryPointSource: string;
|
||||
readonly executableRole?: never;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,33 @@ const TARGET: BinaryTarget = {
|
|||
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
|
||||
|
||||
it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => {
|
||||
const selected = {
|
||||
path: TARGET.path,
|
||||
sha256: TARGET.sha256,
|
||||
format: "raw-binary" as const,
|
||||
architecture: "6502" as const,
|
||||
};
|
||||
expect(
|
||||
createEvidence(selected, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}).subject,
|
||||
).toMatchObject({ format: "raw-binary", architecture: "6502" });
|
||||
const malformed = {
|
||||
...selected,
|
||||
format: "pe" as const,
|
||||
};
|
||||
expect(() =>
|
||||
createEvidence(malformed, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => {
|
||||
const observation = {
|
||||
operation: "procedure_callees",
|
||||
|
|
|
|||
|
|
@ -38,42 +38,52 @@ export const providerSchema = z.strictObject({
|
|||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
});
|
||||
const subjectSchema = z.strictObject({
|
||||
const subjectFormatSchema = z.enum([
|
||||
"hopper",
|
||||
"analysis-database",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"dos-mz",
|
||||
"dos-com",
|
||||
"raw-binary",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"hap",
|
||||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]);
|
||||
const subjectBaseSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
digest: z.strictObject({ sha256: digestSchema }),
|
||||
format: z.enum([
|
||||
"hopper",
|
||||
"analysis-database",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"dos-mz",
|
||||
"dos-com",
|
||||
"raw-binary",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"hap",
|
||||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
local_path: nulFreeStringSchema,
|
||||
});
|
||||
// The format owns the architecture claim, including nested Evidence subjects.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
}),
|
||||
]);
|
||||
/** Source location attached to an evidence observation. */
|
||||
/** Source-location schema shared by evidence-bearing persistence formats. */
|
||||
export const evidenceLocationSchema = z.discriminatedUnion("kind", [
|
||||
|
|
@ -186,7 +196,7 @@ const immutableResultSchema = z
|
|||
});
|
||||
export type EvidenceLocation = z.infer<typeof evidenceLocationSchema>;
|
||||
|
||||
/** Minimal immutable local artifact identity accepted by Evidence. */
|
||||
/** Candidate local artifact identity validated by the Evidence subject schema. */
|
||||
export interface EvidenceSubjectTarget {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
|
|
|
|||
|
|
@ -16,14 +16,17 @@ import {
|
|||
} from "./residualUnknown.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
const artifactManifestSchema = evidenceSchema.shape.subject
|
||||
.unwrap()
|
||||
.pick({
|
||||
digest: true,
|
||||
format: true,
|
||||
architecture: true,
|
||||
})
|
||||
.strict();
|
||||
const [detectedSubjectSchema, rawSubjectSchema] =
|
||||
evidenceSchema.shape.subject.unwrap().options;
|
||||
const manifestFields = {
|
||||
digest: true,
|
||||
format: true,
|
||||
architecture: true,
|
||||
} as const;
|
||||
const artifactManifestSchema = z.discriminatedUnion("format", [
|
||||
detectedSubjectSchema.pick(manifestFields),
|
||||
rawSubjectSchema.pick(manifestFields),
|
||||
]);
|
||||
const providerManifestSchema = providerSchema;
|
||||
const environmentManifestSchema = evidenceSchema.shape.environment.unwrap();
|
||||
const scenarioManifestSchema = z.strictObject({
|
||||
|
|
@ -85,11 +88,11 @@ export const createEvidenceBundle = (
|
|||
subject === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
artifactManifestSchema.parse({
|
||||
digest: subject.digest,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
|
||||
const returnedValue = (getter: string, write = "box.v.x = 2;") =>
|
||||
onlyCallable(
|
||||
analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1, y: 1 };
|
||||
const box = { s: shared, get v() { ${getter} } };
|
||||
${write}
|
||||
return shared.x;
|
||||
}`),
|
||||
"result",
|
||||
).returnSites[0]?.value;
|
||||
|
||||
describe("projected private getter receivers", () => {
|
||||
it.each([
|
||||
[
|
||||
"destructured self field",
|
||||
"shared",
|
||||
"peer = this;",
|
||||
"const { peer: self } = this; return self.#s;",
|
||||
true,
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"unrelated private child",
|
||||
"{ peer: shared }",
|
||||
"peer = this;",
|
||||
"const { peer: self } = this; return self.#s;",
|
||||
true,
|
||||
"literal",
|
||||
],
|
||||
[
|
||||
"read without write",
|
||||
"shared",
|
||||
"peer = this;",
|
||||
"const { peer: self } = this; return self.#s;",
|
||||
false,
|
||||
"literal",
|
||||
],
|
||||
[
|
||||
"two self fields",
|
||||
"shared",
|
||||
"peer = this; next = this.peer;",
|
||||
"const { next: self } = this; return self.#s;",
|
||||
true,
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"nested receiver projection",
|
||||
"shared",
|
||||
"peer = this;",
|
||||
"const { peer: { peer: self } } = this; return self.#s;",
|
||||
true,
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"member receiver alias",
|
||||
"shared",
|
||||
"peer = this;",
|
||||
"const self = this.peer.peer; return self.#s;",
|
||||
true,
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"projected result suffix",
|
||||
"{ child: shared }",
|
||||
"peer = this;",
|
||||
"const { peer: self } = this; return self.#s.child;",
|
||||
true,
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"external receiver",
|
||||
"shared",
|
||||
"peer = external;",
|
||||
"const { peer: self } = this; return self.#s;",
|
||||
true,
|
||||
"literal",
|
||||
],
|
||||
[
|
||||
"field receiver cycle",
|
||||
"shared",
|
||||
"peer = this.other; other = this.peer;",
|
||||
"const { peer: self } = this; return self.#s;",
|
||||
true,
|
||||
"literal",
|
||||
],
|
||||
] as const)(
|
||||
"keeps receiver and result paths separate for %s",
|
||||
(_name, value, fields, getter, write, status) => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
class Box {
|
||||
#s = ${value}; ${fields}
|
||||
get v() { ${getter} }
|
||||
}
|
||||
const box = new Box();
|
||||
${write ? "box.v.x = 2;" : "const saved = box.v;"}
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject(
|
||||
status === "literal" ? { status, value: 1 } : { status },
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("private receiver field ownership", () => {
|
||||
it("bounds growing receiver projections under cyclic class inheritance", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
class A extends B {
|
||||
#s = shared;
|
||||
peer = this.other.other;
|
||||
get v() { const { peer: self } = this; return self.#s; }
|
||||
}
|
||||
class B extends A { other = this.peer.peer; }
|
||||
new A().v.x = 2;
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"own static receiver",
|
||||
"class Box { static #s=shared; static peer=this; static get v(){const {peer:self}=this;return self.#s;} } Box.v.x=2;",
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"inherited instance receiver",
|
||||
"class Base {peer=this;} class Box extends Base {#s=shared;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;",
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"inherited static field retains its declaring class",
|
||||
"class Base {static peer=this;} class Box extends Base {static #s=shared;static get v(){const {peer:self}=this;return self.#s;}} Box.v.x=2;",
|
||||
"literal",
|
||||
],
|
||||
[
|
||||
"inherited static getter's own private field",
|
||||
"class Base {static #s=shared;static peer=this;static get v(){const {peer:self}=this;return self.#s;}} class Box extends Base {} Box.v.x=2;",
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"a field that is not initialized yet",
|
||||
"class Box {#s=shared;peer=this.later;later=this;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;",
|
||||
"literal",
|
||||
],
|
||||
[
|
||||
"remaining getter projection after a captured field",
|
||||
"class Box {#s=shared;peer=this;next=this.peer;later=this;get v(){const {next:{later:self}}=this;return self.#s;}} new Box().v.x=2;",
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"a replaced self field",
|
||||
"class Box {#s=shared;peer=this;peer=external;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;",
|
||||
"literal",
|
||||
],
|
||||
] as const)("respects %s", (_name, body, status) => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
${body}
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject(
|
||||
status === "literal" ? { status, value: 1 } : { status },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getter-local receiver aliases (#2653)", () => {
|
||||
it.each([
|
||||
["initialized alias", "const self = this; return self.s;"],
|
||||
["assigned alias", "let self; self = this; return self.s;"],
|
||||
[
|
||||
"chained assignment",
|
||||
"let self; let alias; self = alias = this; return self.s;",
|
||||
],
|
||||
["destructured receiver", "const { s: value } = this; return value;"],
|
||||
["alias chain", "const self = this; const saved = self; return saved.s;"],
|
||||
[
|
||||
"capture before reassignment",
|
||||
"let self = this; const saved = self; self = { s: { x: 1 } }; return saved.s;",
|
||||
],
|
||||
[
|
||||
"conditional receiver alias",
|
||||
"const self = flag ? this : { s: { x: 1 } }; return self.s;",
|
||||
],
|
||||
])("invalidates shared values through %s", (_name, getter) => {
|
||||
expect(returnedValue(getter)?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no write", "const self = this; return self.s;", "const saved = box.v;"],
|
||||
[
|
||||
"an unrelated property write",
|
||||
"const self = this; return self.s;",
|
||||
"box.v.y = 2;",
|
||||
],
|
||||
[
|
||||
"a cycle that does not grow the selected path",
|
||||
"let self = this; while (flag) self = self; return self.s;",
|
||||
"box.v.y = 2;",
|
||||
],
|
||||
[
|
||||
"an alias replaced before return",
|
||||
"let self = this; self = { s: { x: 1 } }; return self.s;",
|
||||
"box.v.x = 2;",
|
||||
],
|
||||
[
|
||||
"a shadowing block binding",
|
||||
"const self = this; { const self = { s: { x: 1 } }; return self.s; }",
|
||||
"box.v.x = 2;",
|
||||
],
|
||||
[
|
||||
"a nested function's receiver",
|
||||
"function other() { const self = this; return self.s; } return other();",
|
||||
"box.v.x = 2;",
|
||||
],
|
||||
[
|
||||
"an alias assigned in a different receiver scope",
|
||||
"let self; function assign() { self = this; } assign(); return self.s;",
|
||||
"box.v.x = 2;",
|
||||
],
|
||||
[
|
||||
"an excluded destructuring default",
|
||||
"const { value = shared } = { value: { x: 1 } }; return value;",
|
||||
"box.v.x = 2;",
|
||||
],
|
||||
])("preserves known values with %s", (_name, getter, write) => {
|
||||
expect(returnedValue(getter, write)).toMatchObject({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getter receiver alias boundaries", () => {
|
||||
it("does not reinterpret an outer this binding as the getter receiver", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
const outside = this;
|
||||
const box = { s: shared, get v() { return outside.s; } };
|
||||
box.v.x = 2;
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a private field's lexical owner through a receiver alias", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
class Box {
|
||||
#s = shared;
|
||||
get v() { const self = this; return self.#s; }
|
||||
}
|
||||
new Box().v.x = 2;
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append a receiver projection to a private field's result path", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
class Box {
|
||||
#s = { peer: shared };
|
||||
peer = this;
|
||||
get v() { const { peer: self } = this; return self.#s; }
|
||||
}
|
||||
new Box().v.x = 2;
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a long local alias chain without recursive expansion", () => {
|
||||
const aliases = ["const a0 = this;"];
|
||||
for (let index = 1; index <= 2000; index++)
|
||||
aliases.push(`const a${String(index)} = a${String(index - 1)};`);
|
||||
expect(returnedValue(`${aliases.join("\n")} return a2000.s;`)?.status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds a growing alias cycle without losing the original receiver", () => {
|
||||
expect(
|
||||
returnedValue(
|
||||
"let self = this; while (flag) self = self.s; return self.s;",
|
||||
)?.status,
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"retains deeper shared references through an alias cycle (write=%s)",
|
||||
(write) => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
const box = {
|
||||
start: { child: { child: shared } },
|
||||
get v() {
|
||||
let self = this.start;
|
||||
while (flag) self = self.child;
|
||||
return self;
|
||||
}
|
||||
};
|
||||
${write ? "box.v.x = 2;" : "const saved = box.v;"}
|
||||
return shared.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject(
|
||||
write ? { status: "unknown" } : { status: "literal", value: 1 },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not widen a growing cycle to unrelated receiver properties", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`export function result() {
|
||||
const shared = { x: 1 };
|
||||
const unrelated = { x: 1 };
|
||||
const box = {
|
||||
start: { child: { child: shared } },
|
||||
unrelated,
|
||||
get v() {
|
||||
let self = this.start;
|
||||
while (flag) self = self.child;
|
||||
return self;
|
||||
}
|
||||
};
|
||||
box.v.x = 2;
|
||||
return unrelated.x;
|
||||
}`);
|
||||
expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({
|
||||
status: "literal",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,10 +10,7 @@ import {
|
|||
evaluateSemanticBinding,
|
||||
evaluateSemanticExpression,
|
||||
} from "./javascriptSemanticValues.js";
|
||||
import {
|
||||
semanticContainer,
|
||||
semanticSlotAtPath,
|
||||
} from "./javascriptSemanticSlots.js";
|
||||
import { semanticContainer } from "./javascriptSemanticSlots.js";
|
||||
import {
|
||||
semanticClassCandidates,
|
||||
semanticClassPropertyReferences,
|
||||
|
|
@ -24,6 +21,10 @@ import { semanticIterationSources } from "./javascriptSemanticIterationSources.j
|
|||
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
|
||||
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
|
||||
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
|
||||
import {
|
||||
semanticReferenceSourcesInactive,
|
||||
semanticSelectedReferenceSlot,
|
||||
} from "./javascriptSemanticReferenceSources.js";
|
||||
import {
|
||||
semanticArrayIndex,
|
||||
SemanticPropertyPathCoverage,
|
||||
|
|
@ -329,7 +330,17 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
for (const value of effect === "escape"
|
||||
? escapedReferenceLeaves(expression, current.path)
|
||||
: expandedValues(expression, current.path, effect)) {
|
||||
if (value.iterableFallbackPath !== undefined)
|
||||
if (value.escapes && effect !== "escape")
|
||||
markValue(
|
||||
value.node,
|
||||
value.path,
|
||||
new Set(),
|
||||
"escape",
|
||||
mutation,
|
||||
current.originAt,
|
||||
value.ownerInitializer,
|
||||
);
|
||||
else if (value.iterableFallbackPath !== undefined)
|
||||
deferIterable({
|
||||
node: value.node,
|
||||
projection: [],
|
||||
|
|
@ -504,7 +515,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
for (const [index, reference] of pendingReferences.splice(0).entries()) {
|
||||
if (index % 64 === 0) yield;
|
||||
const { initializer } = reference;
|
||||
if (referenceSourcesInactive(initializer, state))
|
||||
if (semanticReferenceSourcesInactive(initializer, state))
|
||||
pendingReferences.push(reference);
|
||||
else if (t.isForOfStatement(initializer.node))
|
||||
deferIterable({
|
||||
|
|
@ -549,7 +560,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
}
|
||||
for (const [index, reference] of [...iterableReferences].entries()) {
|
||||
if (index % 64 === 0) yield;
|
||||
if (referenceSourcesInactive(reference, state)) continue;
|
||||
if (semanticReferenceSourcesInactive(reference, state)) continue;
|
||||
if (reference.iterationOnly) {
|
||||
const expanded = expandedIterationReferences(reference, state);
|
||||
if (expanded !== null) {
|
||||
|
|
@ -557,7 +568,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
continue;
|
||||
}
|
||||
}
|
||||
const slot = selectedReferenceSlot(reference, state);
|
||||
const slot = semanticSelectedReferenceSlot(reference, state);
|
||||
// Custom iteration can yield the iterable itself as well as any child.
|
||||
// Treat its root as escaped, including writes through yielded references.
|
||||
const knownArray =
|
||||
|
|
@ -610,27 +621,6 @@ interface IterableReference extends ReferencedValue {
|
|||
}[];
|
||||
}
|
||||
|
||||
const referenceSourcesInactive = (
|
||||
reference: Pick<IterableReference, "requiredSources" | "fallbackSources">,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean =>
|
||||
reference.requiredSources?.some(
|
||||
(source) =>
|
||||
selectedReferenceSlot(source, state, source.node)?.presence === "absent",
|
||||
) === true ||
|
||||
reference.fallbackSources?.some((source) => {
|
||||
// Selection precedes effects through the chosen reference. A later escape
|
||||
// cannot make a fallback that was excluded at destructuring reachable.
|
||||
const slot = selectedReferenceSlot(source, state, source.node);
|
||||
return (
|
||||
slot?.presence === "present" &&
|
||||
(slot.value.status === "literal" ||
|
||||
slot.value.status === "union" ||
|
||||
slot.value.status === "object" ||
|
||||
slot.value.status === "array")
|
||||
);
|
||||
}) === true;
|
||||
|
||||
const expandedIterationReferences = (
|
||||
reference: IterableReference,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
|
|
@ -645,7 +635,10 @@ const expandedIterationReferences = (
|
|||
...(reference.fallbackSources ?? []),
|
||||
].find((source) => source.node === iterable && source.projection[0] === null);
|
||||
if (source === undefined) return null;
|
||||
const slot = selectedReferenceSlot({ node: iterable, projection: [] }, state);
|
||||
const slot = semanticSelectedReferenceSlot(
|
||||
{ node: iterable, projection: [] },
|
||||
state,
|
||||
);
|
||||
const container = slot === undefined ? null : semanticContainer(slot.value);
|
||||
if (container?.coverage.status !== "complete") return null;
|
||||
const select = (
|
||||
|
|
@ -1141,19 +1134,3 @@ const copiedReferencePath = (
|
|||
}
|
||||
return [...initializer.projection.slice(0, offset), key, ...remaining];
|
||||
};
|
||||
|
||||
const selectedReferenceSlot = (
|
||||
source: { readonly node: t.Node; readonly projection: PropertyPath },
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
capturePoint?: t.Node,
|
||||
) => {
|
||||
const path: string[] = [];
|
||||
for (const key of source.projection) {
|
||||
if (key === null || typeof key === "object") return undefined;
|
||||
path.push(String(key));
|
||||
}
|
||||
return semanticSlotAtPath(
|
||||
evaluateSemanticExpression(source.node, state, capturePoint),
|
||||
path,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -149,7 +149,50 @@ export const semanticMutationInitializers = (
|
|||
const mutationPosition =
|
||||
directMutationPosition(mutation, parents) ??
|
||||
referenceInitializerPosition(mutation, parents);
|
||||
if (mutationPosition === null) return binding;
|
||||
return selectInitializers(binding, mutationPosition, parents);
|
||||
};
|
||||
|
||||
/** Select a local alias at its read, before any later straight-line reassignment. */
|
||||
export const semanticReadInitializers = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
read: t.Node,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
): Pick<
|
||||
JavaScriptSemanticBindingState,
|
||||
"initializers" | "referenceInitializers"
|
||||
> => {
|
||||
let position: StatementPosition | null = null;
|
||||
let current = read;
|
||||
for (
|
||||
let parent = parents.get(current);
|
||||
parent;
|
||||
parent = parents.get(current)
|
||||
) {
|
||||
if (t.isLoop(parent)) return binding;
|
||||
if (t.isFunction(parent)) break;
|
||||
if (position === null) {
|
||||
if (t.isVariableDeclarator(parent)) {
|
||||
const declaration = parents.get(parent);
|
||||
if (t.isVariableDeclaration(declaration))
|
||||
position = statementPosition(declaration, parents, parent);
|
||||
} else if (t.isStatement(parent))
|
||||
position = statementPosition(parent, parents);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return selectInitializers(binding, position, parents, true);
|
||||
};
|
||||
|
||||
const selectInitializers = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
position: StatementPosition | null,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
atRead = false,
|
||||
): Pick<
|
||||
JavaScriptSemanticBindingState,
|
||||
"initializers" | "referenceInitializers"
|
||||
> => {
|
||||
if (position === null) return binding;
|
||||
const origins = [
|
||||
...binding.initializers.map((initializer) => ({
|
||||
initializer,
|
||||
|
|
@ -169,12 +212,20 @@ export const semanticMutationInitializers = (
|
|||
];
|
||||
if (
|
||||
origins.some(
|
||||
({ position }) =>
|
||||
position === null || !positionPrecedes(position, mutationPosition),
|
||||
({ position: origin }) =>
|
||||
origin === null ||
|
||||
origin.body !== position.body ||
|
||||
(!atRead && !positionPrecedes(origin, position)),
|
||||
)
|
||||
)
|
||||
return binding;
|
||||
const latest = origins.reduce<StatementPosition | null>(
|
||||
const preceding = atRead
|
||||
? origins.filter(
|
||||
({ position: origin }) =>
|
||||
origin !== null && positionPrecedes(origin, position),
|
||||
)
|
||||
: origins;
|
||||
const latest = preceding.reduce<StatementPosition | null>(
|
||||
(latest, { position }) =>
|
||||
position !== null &&
|
||||
(latest === null || positionPrecedes(latest, position))
|
||||
|
|
@ -183,7 +234,7 @@ export const semanticMutationInitializers = (
|
|||
null,
|
||||
);
|
||||
const retained = new Set(
|
||||
origins
|
||||
preceding
|
||||
.filter(
|
||||
({ position }) =>
|
||||
position?.index === latest?.index &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
|
||||
import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js";
|
||||
import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
unwrapJavaScriptExpression,
|
||||
|
|
@ -17,6 +19,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|||
export interface JavaScriptSemanticObjectReference {
|
||||
readonly node: t.Node;
|
||||
readonly path: PropertyPath;
|
||||
readonly escapes?: true;
|
||||
/** The allocation owned by this binding, rather than a read of its prior value. */
|
||||
readonly ownerInitializer?: t.Node;
|
||||
}
|
||||
|
|
@ -73,6 +76,7 @@ const instanceFieldReferences = (
|
|||
propertyName: string | null,
|
||||
path: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
initializedBefore?: t.ClassProperty,
|
||||
): {
|
||||
readonly references: readonly JavaScriptSemanticObjectReference[];
|
||||
readonly definiteNames: ReadonlySet<string>;
|
||||
|
|
@ -94,7 +98,15 @@ const instanceFieldReferences = (
|
|||
if (previous !== undefined && previous.size === excluded.size) continue;
|
||||
selections.set(current.klass, excluded);
|
||||
const shadowed = new Set(excluded);
|
||||
for (const member of [...current.klass.body.body].reverse()) {
|
||||
const before =
|
||||
initializedBefore === undefined
|
||||
? -1
|
||||
: current.klass.body.body.indexOf(initializedBefore);
|
||||
const members =
|
||||
before < 0
|
||||
? current.klass.body.body
|
||||
: current.klass.body.body.slice(0, before);
|
||||
for (const member of [...members].reverse()) {
|
||||
if (!t.isClassProperty(member) || member.static) continue;
|
||||
const key = semanticStaticPropertyKey(member.key, member.computed);
|
||||
if (key !== null && shadowed.has(key)) continue;
|
||||
|
|
@ -123,12 +135,15 @@ interface ClassPropertyLookup {
|
|||
readonly owner: t.ClassDeclaration | t.ClassExpression;
|
||||
readonly receiver?: t.ClassDeclaration | t.ClassExpression;
|
||||
readonly kind: "instance" | "static" | "prototype";
|
||||
readonly fieldsOnly?: true;
|
||||
readonly initializedBefore?: t.ClassProperty;
|
||||
}
|
||||
|
||||
const classPropertyDescriptors = (
|
||||
klass: t.ClassDeclaration | t.ClassExpression,
|
||||
propertyName: string | null,
|
||||
kind: ClassPropertyLookup["kind"],
|
||||
initializedBefore?: t.ClassProperty,
|
||||
) => {
|
||||
const isStatic = kind === "static";
|
||||
const selectedGetters: t.ClassMethod[] = [];
|
||||
|
|
@ -138,7 +153,12 @@ const classPropertyDescriptors = (
|
|||
readonly index: number;
|
||||
readonly key: string | null;
|
||||
}[] = [];
|
||||
const before =
|
||||
initializedBefore === undefined
|
||||
? -1
|
||||
: klass.body.body.indexOf(initializedBefore);
|
||||
klass.body.body.forEach((member, index) => {
|
||||
if (before >= 0 && index >= before && t.isClassProperty(member)) return;
|
||||
if (
|
||||
(t.isClassMethod(member) ||
|
||||
(kind === "static" && t.isClassProperty(member))) &&
|
||||
|
|
@ -223,7 +243,13 @@ export const semanticClassPropertyReferences = (
|
|||
// constructors. They take precedence over every prototype descriptor.
|
||||
let instanceFields: ReadonlySet<string> = new Set();
|
||||
if (lookup.kind === "instance") {
|
||||
const fields = instanceFieldReferences(root, propertyName, path, state);
|
||||
const fields = instanceFieldReferences(
|
||||
root,
|
||||
propertyName,
|
||||
path,
|
||||
state,
|
||||
lookup.initializedBefore,
|
||||
);
|
||||
possibleResults.push(...fields.references);
|
||||
instanceFields = fields.definiteNames;
|
||||
if (propertyName !== null && instanceFields.has(propertyName))
|
||||
|
|
@ -237,11 +263,12 @@ export const semanticClassPropertyReferences = (
|
|||
klass,
|
||||
propertyName,
|
||||
lookup.kind,
|
||||
lookup.initializedBefore,
|
||||
);
|
||||
for (const field of descriptors.fields)
|
||||
if (field.value != null)
|
||||
possibleResults.push({ node: field.value, path });
|
||||
for (const getter of descriptors.getters) {
|
||||
for (const getter of lookup.fieldsOnly ? [] : descriptors.getters) {
|
||||
const key = semanticStaticPropertyKey(getter.key, getter.computed);
|
||||
if (key !== null && instanceFields.has(key)) continue;
|
||||
possibleResults.push(
|
||||
|
|
@ -345,6 +372,102 @@ interface GetterInvocation {
|
|||
| t.ClassExpression;
|
||||
}
|
||||
|
||||
/** Follow only data fields whose initializers retain a class receiver. */
|
||||
const privateReceiverProjection = (
|
||||
receiver: t.ClassDeclaration | t.ClassExpression,
|
||||
owner: t.ClassDeclaration | t.ClassExpression,
|
||||
path: PropertyPath,
|
||||
isStatic: boolean,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
if (path.some((key) => key === null || typeof key === "object")) return false;
|
||||
type Selection = { readonly key: string; readonly before?: t.ClassProperty };
|
||||
const pending: {
|
||||
receiver: typeof receiver;
|
||||
path: readonly Selection[];
|
||||
visits: ReadonlyMap<string, number>;
|
||||
}[] = [
|
||||
{
|
||||
receiver,
|
||||
path: path.map((key) => ({ key: String(key) })),
|
||||
visits: new Map(),
|
||||
},
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const declaringClass = (field: t.ClassProperty) => {
|
||||
const body = state.parentsByNode.get(field);
|
||||
return body === undefined ? undefined : state.parentsByNode.get(body);
|
||||
};
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
const current = pending[cursor];
|
||||
if (current === undefined) break;
|
||||
const [selection, ...remaining] = current.path;
|
||||
if (selection === undefined) {
|
||||
if (!isStatic || current.receiver === owner) return true;
|
||||
continue;
|
||||
}
|
||||
const origin = JSON.stringify([
|
||||
current.receiver.start,
|
||||
selection.key,
|
||||
selection.before?.start,
|
||||
]);
|
||||
const previous = current.visits.get(origin);
|
||||
// Repeated fields may consume a finite getter path. An initializer cycle
|
||||
// that preserves or grows it cannot establish another receiver identity.
|
||||
if (previous !== undefined && previous <= current.path.length) continue;
|
||||
const visits = new Map(current.visits).set(origin, current.path.length);
|
||||
const identity = JSON.stringify([
|
||||
current.receiver.start,
|
||||
current.path.map(({ key, before }) => [key, before?.start]),
|
||||
]);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
const lookupOwner =
|
||||
selection.before === undefined
|
||||
? current.receiver
|
||||
: declaringClass(selection.before);
|
||||
if (!t.isClass(lookupOwner)) continue;
|
||||
for (const reference of semanticClassPropertyReferences(
|
||||
{
|
||||
owner: lookupOwner,
|
||||
receiver: current.receiver,
|
||||
kind: isStatic ? "static" : "instance",
|
||||
fieldsOnly: true,
|
||||
...(selection.before === undefined
|
||||
? {}
|
||||
: { initializedBefore: selection.before }),
|
||||
},
|
||||
[selection.key],
|
||||
state,
|
||||
)) {
|
||||
const field = state.parentsByNode.get(reference.node);
|
||||
if (!t.isClassProperty(field)) continue;
|
||||
const fieldOwner = declaringClass(field);
|
||||
if (!t.isClass(fieldOwner)) continue;
|
||||
const projection: Selection[] = [];
|
||||
let value = unwrapJavaScriptExpression(reference.node).node;
|
||||
while (
|
||||
t.isMemberExpression(value) ||
|
||||
t.isOptionalMemberExpression(value)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(value.property, value.computed);
|
||||
if (key === null || t.isPrivateName(value.property)) break;
|
||||
projection.unshift({ key, before: field });
|
||||
value = unwrapJavaScriptExpression(value.object).node;
|
||||
}
|
||||
if (!t.isThisExpression(value)) continue;
|
||||
// Instance fields retain the actual instance. Static field `this` belongs
|
||||
// to its declaring class, even when a subclass inherits the field.
|
||||
pending.push({
|
||||
receiver: isStatic ? fieldOwner : current.receiver,
|
||||
path: [...projection, ...remaining],
|
||||
visits,
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getterResultReferences = (
|
||||
invocation: GetterInvocation,
|
||||
path: PropertyPath,
|
||||
|
|
@ -358,40 +481,206 @@ const getterResultReferences = (
|
|||
// path; recursive getters that preserve or grow that path cannot progress.
|
||||
const active = new Map(activeGetters).set(getter, path.length);
|
||||
const isStatic = t.isClassMethod(getter) && getter.static;
|
||||
const resolve = (
|
||||
node: t.Node,
|
||||
remaining: PropertyPath,
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
const expression = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isThisExpression(expression)) {
|
||||
if (t.isClass(receiver)) {
|
||||
return semanticClassPropertyReferences(
|
||||
{ owner: receiver, kind: isStatic ? "static" : "instance" },
|
||||
remaining,
|
||||
state,
|
||||
active,
|
||||
);
|
||||
interface AliasVisit {
|
||||
readonly identity: string;
|
||||
readonly pathLength: number;
|
||||
readonly parent: AliasVisit | undefined;
|
||||
}
|
||||
const pending: {
|
||||
readonly node: t.Node;
|
||||
readonly path: PropertyPath;
|
||||
readonly aliases: AliasVisit | undefined;
|
||||
readonly escapes?: true;
|
||||
readonly privateRead?: {
|
||||
readonly node: t.MemberExpression | t.OptionalMemberExpression;
|
||||
readonly name: string;
|
||||
readonly path: PropertyPath;
|
||||
};
|
||||
}[] = semanticCallableResultExpressions(getter).flatMap(({ node }) =>
|
||||
node === null ? [] : [{ node, path, aliases: undefined }],
|
||||
);
|
||||
const references: JavaScriptSemanticObjectReference[] = [];
|
||||
const expandedAliases = new Map<string, Set<string>>();
|
||||
const getterScope = state.scopeByNode.get(getter)?.scopeId;
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
const current = pending[cursor];
|
||||
if (current === undefined) break;
|
||||
const { path: remaining } = current;
|
||||
const expression = unwrapJavaScriptExpression(current.node).node;
|
||||
const retain = (reference: JavaScriptSemanticObjectReference): void => {
|
||||
references.push(
|
||||
current.escapes ? { ...reference, escapes: true } : reference,
|
||||
);
|
||||
};
|
||||
const retainPrivateRead = (): void => {
|
||||
if (current.privateRead !== undefined)
|
||||
retain({
|
||||
node: current.privateRead.node,
|
||||
path: current.escapes ? [] : current.privateRead.path,
|
||||
});
|
||||
};
|
||||
if (t.isIdentifier(expression)) {
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
expression,
|
||||
expression.name,
|
||||
);
|
||||
let scope =
|
||||
binding === undefined
|
||||
? undefined
|
||||
: state.scopesById.get(binding.scopeId);
|
||||
while (scope !== undefined && scope.scopeId !== getterScope) {
|
||||
if (scope.kind === "function" || scope.parentScopeId === null) {
|
||||
scope = undefined;
|
||||
break;
|
||||
}
|
||||
scope = state.scopesById.get(scope.parentScopeId);
|
||||
}
|
||||
return objectReceiverReference(receiver, remaining, state);
|
||||
if (
|
||||
binding !== undefined &&
|
||||
scope !== undefined &&
|
||||
binding.referenceInitializers.every(
|
||||
(initializer) => initializer.copyKind === undefined,
|
||||
)
|
||||
) {
|
||||
const identity = JSON.stringify([
|
||||
binding.bindingId,
|
||||
expression.start,
|
||||
expression.end,
|
||||
]);
|
||||
const known = expandedAliases.get(identity);
|
||||
const key = JSON.stringify([
|
||||
remaining,
|
||||
current.privateRead?.name,
|
||||
current.privateRead?.path,
|
||||
current.escapes,
|
||||
]);
|
||||
let cyclic = false;
|
||||
let growing = false;
|
||||
if (known !== undefined)
|
||||
for (
|
||||
let visit = current.aliases;
|
||||
visit !== undefined;
|
||||
visit = visit.parent
|
||||
)
|
||||
if (visit.identity === identity) {
|
||||
cyclic = true;
|
||||
growing = remaining.length > visit.pathLength;
|
||||
break;
|
||||
}
|
||||
if (cyclic) {
|
||||
// A repeated alias can select arbitrarily deep children. Revisit its
|
||||
// roots as escapes rather than enumerating iterations or dropping them.
|
||||
if (growing && !current.escapes)
|
||||
pending.push({
|
||||
...current,
|
||||
path: [],
|
||||
aliases: undefined,
|
||||
escapes: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (known?.has(key)) continue;
|
||||
const selections = known ?? new Set<string>();
|
||||
selections.add(key);
|
||||
expandedAliases.set(identity, selections);
|
||||
const aliases = {
|
||||
identity,
|
||||
pathLength: remaining.length,
|
||||
parent: current.aliases,
|
||||
};
|
||||
const origins = semanticReadInitializers(
|
||||
binding,
|
||||
expression,
|
||||
state.parentsByNode,
|
||||
);
|
||||
for (const initializer of [
|
||||
...origins.initializers,
|
||||
...origins.referenceInitializers.filter(
|
||||
(initializer) =>
|
||||
!semanticReferenceSourcesInactive(initializer, state),
|
||||
),
|
||||
])
|
||||
pending.push({
|
||||
...current,
|
||||
node: initializer.node,
|
||||
path: [...initializer.projection, ...remaining],
|
||||
aliases,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (t.isThisExpression(expression)) {
|
||||
// An outer binding or a nested ordinary function has a different receiver.
|
||||
let ownerNode = state.parentsByNode.get(expression);
|
||||
while (
|
||||
ownerNode !== undefined &&
|
||||
!t.isClass(ownerNode) &&
|
||||
!(t.isFunction(ownerNode) && !t.isArrowFunctionExpression(ownerNode))
|
||||
)
|
||||
ownerNode = state.parentsByNode.get(ownerNode);
|
||||
if (ownerNode !== getter) continue;
|
||||
if (current.privateRead !== undefined && t.isClass(owner)) {
|
||||
if (
|
||||
!t.isClass(receiver) ||
|
||||
!privateReceiverProjection(
|
||||
receiver,
|
||||
owner,
|
||||
remaining,
|
||||
isStatic,
|
||||
state,
|
||||
)
|
||||
) {
|
||||
retainPrivateRead();
|
||||
continue;
|
||||
}
|
||||
for (const member of owner.body.body)
|
||||
if (
|
||||
t.isClassPrivateProperty(member) &&
|
||||
member.static === isStatic &&
|
||||
member.key.id.name === current.privateRead.name &&
|
||||
member.value != null
|
||||
)
|
||||
retain({
|
||||
node: member.value,
|
||||
path: current.escapes ? [] : current.privateRead.path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const reference of t.isClass(receiver)
|
||||
? semanticClassPropertyReferences(
|
||||
{ owner: receiver, kind: isStatic ? "static" : "instance" },
|
||||
remaining,
|
||||
state,
|
||||
active,
|
||||
)
|
||||
: objectReceiverReference(receiver, remaining, state))
|
||||
retain(reference);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
t.isMemberExpression(expression) ||
|
||||
t.isOptionalMemberExpression(expression)
|
||||
) {
|
||||
if (
|
||||
t.isPrivateName(expression.property) &&
|
||||
t.isThisExpression(expression.object) &&
|
||||
t.isClass(owner)
|
||||
current.privateRead !== undefined &&
|
||||
t.isPrivateName(expression.property)
|
||||
) {
|
||||
const privateName = expression.property.id.name;
|
||||
return owner.body.body.flatMap((member) =>
|
||||
t.isClassPrivateProperty(member) &&
|
||||
member.static === isStatic &&
|
||||
member.key.id.name === privateName &&
|
||||
member.value != null
|
||||
? [{ node: member.value, path: remaining }]
|
||||
: [],
|
||||
);
|
||||
retainPrivateRead();
|
||||
continue;
|
||||
}
|
||||
if (t.isPrivateName(expression.property) && t.isClass(owner)) {
|
||||
pending.push({
|
||||
...current,
|
||||
node: expression.object,
|
||||
path: [],
|
||||
privateRead: {
|
||||
node: expression,
|
||||
name: expression.property.id.name,
|
||||
path: remaining,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const key = semanticStaticPropertyKey(
|
||||
expression.property,
|
||||
|
|
@ -402,41 +691,52 @@ const getterResultReferences = (
|
|||
t.isClass(owner) &&
|
||||
owner.superClass != null
|
||||
) {
|
||||
return semanticClassCandidates(owner.superClass, state).flatMap(
|
||||
(base) =>
|
||||
semanticClassPropertyReferences(
|
||||
{
|
||||
owner: base,
|
||||
receiver: t.isClass(receiver) ? receiver : owner,
|
||||
kind: isStatic ? "static" : "prototype",
|
||||
},
|
||||
[key, ...remaining],
|
||||
state,
|
||||
active,
|
||||
),
|
||||
);
|
||||
for (const base of semanticClassCandidates(owner.superClass, state))
|
||||
for (const reference of semanticClassPropertyReferences(
|
||||
{
|
||||
owner: base,
|
||||
receiver: t.isClass(receiver) ? receiver : owner,
|
||||
kind: isStatic ? "static" : "prototype",
|
||||
},
|
||||
[key, ...remaining],
|
||||
state,
|
||||
active,
|
||||
))
|
||||
retain(reference);
|
||||
continue;
|
||||
}
|
||||
return resolve(expression.object, [key, ...remaining]);
|
||||
}
|
||||
if (t.isConditionalExpression(expression))
|
||||
return [
|
||||
...resolve(expression.consequent, remaining),
|
||||
...resolve(expression.alternate, remaining),
|
||||
];
|
||||
if (t.isLogicalExpression(expression))
|
||||
return [
|
||||
...resolve(expression.left, remaining),
|
||||
...resolve(expression.right, remaining),
|
||||
];
|
||||
if (t.isSequenceExpression(expression)) {
|
||||
pending.push({
|
||||
...current,
|
||||
node: expression.object,
|
||||
path: [key, ...remaining],
|
||||
});
|
||||
} else if (t.isConditionalExpression(expression))
|
||||
pending.push(
|
||||
{ ...current, node: expression.consequent },
|
||||
{ ...current, node: expression.alternate },
|
||||
);
|
||||
else if (t.isLogicalExpression(expression))
|
||||
pending.push(
|
||||
{ ...current, node: expression.left },
|
||||
{ ...current, node: expression.right },
|
||||
);
|
||||
else if (t.isSequenceExpression(expression)) {
|
||||
const last = expression.expressions.at(-1);
|
||||
return last === undefined ? [] : resolve(last, remaining);
|
||||
}
|
||||
return [{ node: expression, path: remaining }];
|
||||
};
|
||||
return semanticCallableResultExpressions(getter).flatMap(({ node }) =>
|
||||
node === null ? [] : resolve(node, path),
|
||||
);
|
||||
if (last !== undefined) pending.push({ ...current, node: last });
|
||||
} else if (t.isAssignmentExpression(expression, { operator: "=" }))
|
||||
pending.push({ ...current, node: expression.right });
|
||||
else if (
|
||||
t.isAssignmentExpression(expression) &&
|
||||
["&&=", "||=", "??="].includes(expression.operator)
|
||||
)
|
||||
pending.push(
|
||||
{ ...current, node: expression.left },
|
||||
{ ...current, node: expression.right },
|
||||
);
|
||||
else if (current.privateRead !== undefined) retainPrivateRead();
|
||||
else retain({ node: expression, path: remaining });
|
||||
}
|
||||
return references;
|
||||
};
|
||||
|
||||
export const semanticObjectPropertyReferences = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import type * as t from "@babel/types";
|
||||
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
|
||||
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
|
||||
import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
|
||||
import { semanticSlotAtPath } from "./javascriptSemanticSlots.js";
|
||||
|
||||
interface ReferenceSource {
|
||||
readonly node: t.Node;
|
||||
readonly projection: PropertyPath;
|
||||
}
|
||||
|
||||
/** Exclude references whose source slot or destructuring fallback is absent. */
|
||||
export const semanticReferenceSourcesInactive = (
|
||||
reference: {
|
||||
readonly requiredSources?: readonly ReferenceSource[];
|
||||
readonly fallbackSources?: readonly ReferenceSource[];
|
||||
},
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean =>
|
||||
reference.requiredSources?.some(
|
||||
(source) =>
|
||||
semanticSelectedReferenceSlot(source, state, source.node)?.presence ===
|
||||
"absent",
|
||||
) === true ||
|
||||
reference.fallbackSources?.some((source) => {
|
||||
// Selection precedes effects through the chosen reference. A later escape
|
||||
// cannot make a fallback that was excluded at destructuring reachable.
|
||||
const slot = semanticSelectedReferenceSlot(source, state, source.node);
|
||||
return (
|
||||
slot?.presence === "present" &&
|
||||
(slot.value.status === "literal" ||
|
||||
slot.value.status === "union" ||
|
||||
slot.value.status === "object" ||
|
||||
slot.value.status === "array")
|
||||
);
|
||||
}) === true;
|
||||
|
||||
/** Select a retained source slot at its capture, before later effects. */
|
||||
export const semanticSelectedReferenceSlot = (
|
||||
source: ReferenceSource,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
capturePoint?: t.Node,
|
||||
) => {
|
||||
const path: string[] = [];
|
||||
for (const key of source.projection) {
|
||||
if (key === null || typeof key === "object") return undefined;
|
||||
path.push(String(key));
|
||||
}
|
||||
return semanticSlotAtPath(
|
||||
evaluateSemanticExpression(source.node, state, capturePoint),
|
||||
path,
|
||||
);
|
||||
};
|
||||
|
|
@ -203,10 +203,18 @@ const managedMethodSchema = z.object({
|
|||
body: managedMethodBodySchema,
|
||||
});
|
||||
|
||||
// Declaring types are namespace-qualified like TypeDef `full_name`, nested
|
||||
// types keep bare names, and a generic instantiation names its generic type.
|
||||
// Evidence recorded before declaring types were named reads them as null.
|
||||
const declaringTypeNameSchema = z.string().nullable().default(null);
|
||||
|
||||
const managedMemberReferenceSchema = z.object({
|
||||
token: tokenSchema,
|
||||
row_offset: offsetSchema,
|
||||
parent_token: tokenSchema.nullable(),
|
||||
parent_name: declaringTypeNameSchema.describe(
|
||||
"Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown",
|
||||
),
|
||||
name: z.string(),
|
||||
signature: managedSignatureSchema,
|
||||
});
|
||||
|
|
@ -281,7 +289,13 @@ const managedCallEdgeSchema = z.object({
|
|||
opcode: z.string(),
|
||||
target_token: tokenSchema,
|
||||
target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]),
|
||||
target_name: z.string().nullable(),
|
||||
target_name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Method name; a method-spec target reports its generic method"),
|
||||
target_declaring_type: declaringTypeNameSchema.describe(
|
||||
"Declaring type of the called method; null when unknown",
|
||||
),
|
||||
});
|
||||
|
||||
const managedFieldAccessSchema = z.object({
|
||||
|
|
@ -290,6 +304,9 @@ const managedFieldAccessSchema = z.object({
|
|||
opcode: z.string(),
|
||||
field_token: tokenSchema,
|
||||
field_name: z.string().nullable(),
|
||||
field_declaring_type: declaringTypeNameSchema.describe(
|
||||
"Declaring type of the accessed field; null when unknown",
|
||||
),
|
||||
});
|
||||
|
||||
const managedParseIssueSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { binaryArchitectureSchema } from "../rawBinary.js";
|
||||
|
||||
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
|
|
@ -112,7 +113,7 @@ export const nativeLoadImageSchema = z.union([
|
|||
status: z.enum(["verified", "mismatch"]),
|
||||
format: z.literal("raw-binary"),
|
||||
target_sha256: digest,
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]),
|
||||
architecture: binaryArchitectureSchema,
|
||||
language_id: z.string().min(1),
|
||||
compiler_spec_id: z.string().min(1),
|
||||
base_address: address,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,44 @@ describe("raw-binary entry addresses", () => {
|
|||
});
|
||||
|
||||
describe("raw-binary admission", () => {
|
||||
it("admits byte-aligned 6502 images up to the 16-bit address boundary", () => {
|
||||
for (const [entryPoint, fileBytes] of [
|
||||
["0x8001", 8],
|
||||
["0xffff", 1],
|
||||
["0x0", 65536],
|
||||
] as const) {
|
||||
const result = admitRawBinary({
|
||||
architecture: "6502",
|
||||
entryPoint,
|
||||
fileBytes,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error.reason);
|
||||
expect(result.value).toEqual({
|
||||
architecture: "6502",
|
||||
entryPoint,
|
||||
entryPointSource: entryPoint,
|
||||
});
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
["0x10000", 1],
|
||||
["0xffff", 2],
|
||||
["0x0", 65537],
|
||||
])(
|
||||
"rejects a 6502 mapping at %s with %i bytes that exceeds 16 bits",
|
||||
(entryPoint, fileBytes) => {
|
||||
const result = admitRawBinary({
|
||||
architecture: "6502",
|
||||
entryPoint,
|
||||
fileBytes,
|
||||
});
|
||||
if (result.ok) throw new Error("expected 16-bit overflow rejection");
|
||||
expect(result.error).toEqual({
|
||||
reason: `raw-binary mapping from ${entryPoint} must fit in the 6502 address width`,
|
||||
constraint: "load_selection_rejected",
|
||||
});
|
||||
},
|
||||
);
|
||||
it("admits a little-endian ARM image mapped at the caller entry", () => {
|
||||
expect(
|
||||
admitRawBinary({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const BINARY_ARCHITECTURES = [
|
|||
"x86_64",
|
||||
"arm",
|
||||
"arm64",
|
||||
"6502",
|
||||
] as const satisfies readonly BinaryArchitecture[];
|
||||
|
||||
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
|
||||
|
|
@ -58,7 +59,7 @@ export const validateRawBinaryLength = (
|
|||
|
||||
/** Admitted headerless load identity used by target resolution and profiles. */
|
||||
export interface RawBinaryAdmission {
|
||||
readonly architecture: HeaderArchitecture;
|
||||
readonly architecture: HeaderArchitecture | "6502";
|
||||
readonly entryPoint: string;
|
||||
readonly entryPointSource: string;
|
||||
}
|
||||
|
|
@ -74,9 +75,11 @@ export interface RawBinaryAdmissionRejection {
|
|||
}
|
||||
|
||||
const addressWidth = (architecture: BinaryArchitecture): bigint =>
|
||||
architecture === "x86" || architecture === "arm"
|
||||
? 0xffffffffn
|
||||
: 0xffffffffffffffffn;
|
||||
architecture === "6502"
|
||||
? 0xffffn
|
||||
: architecture === "x86" || architecture === "arm"
|
||||
? 0xffffffffn
|
||||
: 0xffffffffffffffffn;
|
||||
|
||||
/** Validate architecture, entry, and that the mapped image fits the CPU width. */
|
||||
export const admitRawBinary = (input: {
|
||||
|
|
@ -88,7 +91,7 @@ export const admitRawBinary = (input: {
|
|||
if (!architecture.success)
|
||||
return err({
|
||||
reason:
|
||||
"raw-binary format requires architecture (x86, x86_64, arm, or arm64)",
|
||||
"raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)",
|
||||
constraint: "load_selection_rejected",
|
||||
});
|
||||
if (typeof input.entryPoint !== "string")
|
||||
|
|
|
|||
|
|
@ -547,6 +547,63 @@ describe("managed fixture data layout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("managed implementation classification", () => {
|
||||
it("classifies crossgen2 ReadyToRun output without ILONLY as modern .NET", () => {
|
||||
// crossgen2 writes IL_LIBRARY | STRONGNAMESIGNED and clears ILONLY.
|
||||
const bytes = buildManagedPeFixture({ readyToRun: true, cliFlags: 0xc });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.classification).toMatchObject({
|
||||
runtime_family: "modern-dotnet",
|
||||
implementation: "cil-and-ready-to-run",
|
||||
});
|
||||
expect(result.classification.evidence).toContainEqual(
|
||||
expect.objectContaining({ code: "ready-to-run-signature-observed" }),
|
||||
);
|
||||
expect(result.classification.evidence).not.toContainEqual(
|
||||
expect.objectContaining({ code: "cli-il-only-clear" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves ReadyToRun runtime identity after metadata capacity is exhausted", () => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
readyToRun: true,
|
||||
cliFlags: 0xc,
|
||||
references: ["x".repeat(12 * 1024 * 1024)],
|
||||
});
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
expect(result.classification).toMatchObject({
|
||||
status: "managed",
|
||||
runtime_family: "modern-dotnet",
|
||||
});
|
||||
expect(result.coverage.issues).toContainEqual(
|
||||
expect.objectContaining({ code: "resource-limit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a clear ILONLY flag without ReadyToRun as mixed CLR/native", () => {
|
||||
const bytes = buildManagedPeFixture({ cliFlags: 0 });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
|
||||
expect(result.classification).toMatchObject({
|
||||
runtime_family: "mixed-clr-native",
|
||||
implementation: "cpp-cli-mixed",
|
||||
});
|
||||
expect(result.classification.evidence).toContainEqual(
|
||||
expect.objectContaining({ code: "cli-il-only-clear" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed artifact metadata identity", () => {
|
||||
it("accepts CLI metadata GUIDs without RFC UUID version or variant bits", () => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
|
|
|
|||
|
|
@ -158,6 +158,15 @@ interface RuntimeClassificationInput {
|
|||
readonly inventory: ManagedMetadataInventory;
|
||||
}
|
||||
|
||||
const runtimeFamilyFromCliHeader = (
|
||||
cli: ManagedCliHeader | null,
|
||||
): ManagedArtifactInspection["classification"]["runtime_family"] =>
|
||||
cli?.readyToRunSignature === true
|
||||
? "modern-dotnet"
|
||||
: cli !== null && (cli.flags & 1) === 0
|
||||
? "mixed-clr-native"
|
||||
: "unknown";
|
||||
|
||||
const runtimeFamilyFor = (
|
||||
cli: ManagedCliHeader,
|
||||
inventory: ManagedMetadataInventory,
|
||||
|
|
@ -180,7 +189,9 @@ const runtimeFamilyFor = (
|
|||
);
|
||||
let runtimeFamily: ManagedArtifactInspection["classification"]["runtime_family"] =
|
||||
"unknown";
|
||||
if ((cli.flags & 1) === 0) {
|
||||
// crossgen2 clears ILONLY on ReadyToRun output, so only a clear flag
|
||||
// without the ReadyToRun signature indicates mixed CLR/native code.
|
||||
if (runtimeFamilyFromCliHeader(cli) === "mixed-clr-native") {
|
||||
runtimeFamily = "mixed-clr-native";
|
||||
evidence.push({
|
||||
code: "cli-il-only-clear",
|
||||
|
|
@ -239,8 +250,8 @@ const implementationFor = (
|
|||
cli: ManagedCliHeader,
|
||||
metadata: ManagedMetadataLayout,
|
||||
): ManagedArtifactInspection["classification"]["implementation"] => {
|
||||
if ((cli.flags & 1) === 0) return "cpp-cli-mixed";
|
||||
if (cli.readyToRunSignature) return "cil-and-ready-to-run";
|
||||
if ((cli.flags & 1) === 0) return "cpp-cli-mixed";
|
||||
return (metadata.rowCounts[6] ?? 0) === 0 ? "metadata-only" : "cil";
|
||||
};
|
||||
|
||||
|
|
@ -303,8 +314,7 @@ const partialMetadataResult = ({
|
|||
classification: {
|
||||
status: issue.code === "resource-limit" ? "managed" : "malformed",
|
||||
container: "pe",
|
||||
runtime_family:
|
||||
(layout.cli?.flags ?? 1) & 1 ? "unknown" : "mixed-clr-native",
|
||||
runtime_family: runtimeFamilyFromCliHeader(layout.cli),
|
||||
implementation: "unknown",
|
||||
managed_architecture: "unknown",
|
||||
evidence: [
|
||||
|
|
|
|||
|
|
@ -345,6 +345,31 @@ describe("managed exception region prefix boundaries", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("decodes the no. prefix and keeps it with its opcode", () => {
|
||||
// ldarg.0; no. nullcheck; ldfld 0x04000001; pop; ret
|
||||
const il = Buffer.from([2, 0xfe, 0x19, 4, 0x7b, 1, 0, 0, 4, 0x26, 0x2a]);
|
||||
expect(
|
||||
managedBodyWithRegion({ il, tryLength: 10, handlerOffset: 10 }),
|
||||
).toMatchObject({
|
||||
status: "present",
|
||||
issue: null,
|
||||
decoded_instruction_count: 5,
|
||||
opcode_counts: { "ldarg.0": 1, "no.": 1, ldfld: 1, pop: 1, ret: 1 },
|
||||
});
|
||||
expect(
|
||||
managedBodyWithRegion({
|
||||
il,
|
||||
tryOffset: 4,
|
||||
tryLength: 5,
|
||||
handlerOffset: 10,
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "malformed",
|
||||
issue:
|
||||
"Exception clause try range does not align with CIL instruction boundaries",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["start", { tryOffset: 5, tryLength: 1, handlerOffset: 6 }],
|
||||
[
|
||||
|
|
@ -510,3 +535,41 @@ describe("managed exception section decoding", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed CIL truncated operands", () => {
|
||||
const tinyBody = (il: readonly number[]) => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
ilBody: Buffer.from([(il.length << 2) | 0x02, ...il]),
|
||||
});
|
||||
const inspection = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
return { body: inspection.methods[0]?.body, coverage: inspection.coverage };
|
||||
};
|
||||
|
||||
it.each([
|
||||
[[0x20], 0, "Instruction ldc.i4 at IL offset 0 leaves method body"],
|
||||
[
|
||||
[0x00, 0x20, 0x01],
|
||||
1,
|
||||
"Instruction ldc.i4 at IL offset 1 leaves method body",
|
||||
],
|
||||
[[0x2b], 0, "Instruction br.s at IL offset 0 leaves method body"],
|
||||
[[0x45, 0x02], 0, "Instruction switch at IL offset 0 leaves method body"],
|
||||
[
|
||||
[0xfe, 0x09, 0x00],
|
||||
0,
|
||||
"Instruction ldarg at IL offset 0 leaves method body",
|
||||
],
|
||||
[[0x00, 0xfe], 1, "Two-byte CIL opcode at IL offset 1 leaves method body"],
|
||||
])("names the instruction and IL offset for IL %j", (il, decoded, issue) => {
|
||||
const { body, coverage } = tinyBody(il);
|
||||
expect(body).toMatchObject({
|
||||
status: "malformed",
|
||||
decoded_instruction_count: decoded,
|
||||
issue,
|
||||
});
|
||||
expect(coverage.state).toBe("partial");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ describe("managed member inspection", () => {
|
|||
expect(result.member_refs).toEqual([
|
||||
expect.objectContaining({
|
||||
token: "0x0a000001",
|
||||
parent_token: "0x01000001",
|
||||
parent_name: "System.Runtime.Versioning.TargetFrameworkAttribute",
|
||||
name: ".ctor",
|
||||
signature: expect.objectContaining({
|
||||
kind: "method",
|
||||
|
|
@ -126,6 +128,8 @@ describe("managed member inspection", () => {
|
|||
target_token: "0x0a000001",
|
||||
target_kind: "member-ref",
|
||||
target_name: ".ctor",
|
||||
target_declaring_type:
|
||||
"System.Runtime.Versioning.TargetFrameworkAttribute",
|
||||
},
|
||||
]);
|
||||
expect(result.field_accesses).toEqual([
|
||||
|
|
@ -135,12 +139,366 @@ describe("managed member inspection", () => {
|
|||
opcode: "ldfld",
|
||||
field_token: "0x04000001",
|
||||
field_name: "counter",
|
||||
field_declaring_type: "Fixture.Program",
|
||||
},
|
||||
]);
|
||||
expect(result.coverage).toMatchObject({ state: "complete", issues: [] });
|
||||
});
|
||||
});
|
||||
|
||||
/** A tiny-header body of token-operand instructions followed by `ret`. */
|
||||
const tokenBody = (
|
||||
instructions: readonly (readonly [opcode: number, token: number])[],
|
||||
): Buffer => {
|
||||
const il = Buffer.concat([
|
||||
...instructions.map(([opcode, token]) => {
|
||||
const instruction = Buffer.alloc(5);
|
||||
instruction.writeUInt8(opcode, 0);
|
||||
instruction.writeUInt32LE(token, 1);
|
||||
return instruction;
|
||||
}),
|
||||
Buffer.from([0x2a]),
|
||||
]);
|
||||
return Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]);
|
||||
};
|
||||
|
||||
const attribute = "System.Runtime.Versioning.TargetFrameworkAttribute";
|
||||
const inspect = (options: Parameters<typeof buildManagedPeFixture>[0]) => {
|
||||
const bytes = buildManagedPeFixture(options);
|
||||
return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes));
|
||||
};
|
||||
const call = 0x28;
|
||||
const ldsfld = 0x7e;
|
||||
const memberRefOnTypeSpec = (signature: Buffer) =>
|
||||
inspect({
|
||||
typeSpecSignatures: [signature],
|
||||
memberRefParentRaw: (1 << 3) | 4,
|
||||
});
|
||||
|
||||
// HASTHIS | GENERIC, one generic parameter, no parameters, void.
|
||||
const genericRef = Buffer.from([0x30, 0x01, 0x00, 0x01]);
|
||||
// GENERIC, one generic parameter, no parameters, void.
|
||||
const genericDef = Buffer.from([0x10, 0x01, 0x00, 0x01]);
|
||||
|
||||
describe("managed edge target declaring types", () => {
|
||||
it("names a generic TypeSpec parent by its generic type and leaves other TypeSpecs unknown", () => {
|
||||
// GENERICINST CLASS TypeRef(1) <string>
|
||||
const generic = memberRefOnTypeSpec(
|
||||
Buffer.from([0x15, 0x12, (1 << 2) | 1, 0x01, 0x0e]),
|
||||
);
|
||||
expect(generic.member_refs[0]?.parent_name).toBe(attribute);
|
||||
expect(generic.call_edges[0]?.target_declaring_type).toBe(attribute);
|
||||
|
||||
// SZARRAY i4
|
||||
const array = memberRefOnTypeSpec(Buffer.from([0x1d, 0x08]));
|
||||
expect(array.member_refs[0]?.parent_name).toBeNull();
|
||||
expect(array.coverage).toEqual({ state: "complete", issues: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"names a TypeSpec as its generic type",
|
||||
[0x15, 0x12, (1 << 2) | 2, 0x01, 0x0e],
|
||||
"invalid-row",
|
||||
"metadata.TypeSpec:0x1b000001",
|
||||
],
|
||||
[
|
||||
"has no type arguments",
|
||||
[0x15, 0x12, (1 << 2) | 1],
|
||||
"invalid-blob",
|
||||
"metadata.TypeSpec:0x1b000001",
|
||||
],
|
||||
[
|
||||
"names a missing TypeRef",
|
||||
[0x15, 0x12, (9 << 2) | 1, 0x01, 0x0e],
|
||||
"invalid-row",
|
||||
"metadata.TypeSpec:0x1b000001",
|
||||
],
|
||||
[
|
||||
"names a missing TypeDef",
|
||||
[0x15, 0x12, (9 << 2) | 0, 0x01, 0x0e],
|
||||
"invalid-row",
|
||||
"metadata.TypeSpec:0x1b000001",
|
||||
],
|
||||
[
|
||||
"has a type argument naming a missing TypeRef",
|
||||
[0x15, 0x12, (1 << 2) | 1, 0x01, 0x12, (9 << 2) | 1],
|
||||
"invalid-row",
|
||||
"metadata.TypeSpec:0x1b000001",
|
||||
],
|
||||
])(
|
||||
"reports a generic TypeSpec parent that %s and leaves it unknown",
|
||||
(_case, signature, code, scope) => {
|
||||
const result = memberRefOnTypeSpec(Buffer.from(signature));
|
||||
|
||||
expect(result.member_refs[0]?.parent_name).toBeNull();
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [{ code, scope }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports unsupported TypeSpec and MethodSpec forms instead of treating them as unknown", () => {
|
||||
const typeSpec = memberRefOnTypeSpec(Buffer.from([0xff]));
|
||||
const methodSpec = inspect({
|
||||
memberRefSignature: genericRef,
|
||||
methodSpecMethods: [(1 << 1) | 1],
|
||||
methodSpecInstantiation: Buffer.from([0x0a, 0x01, 0xff]),
|
||||
ilBody: tokenBody([[call, 0x2b00_0001]]),
|
||||
});
|
||||
|
||||
expect(typeSpec.member_refs[0]?.parent_name).toBeNull();
|
||||
expect(typeSpec.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
{
|
||||
code: "unsupported-signature",
|
||||
scope: "metadata.TypeSpec:0x1b000001",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(methodSpec.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
{
|
||||
code: "unsupported-signature",
|
||||
scope: "metadata.MethodSpec:0x2b000001",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("names field MemberRefs as fields and leaves mismatched member kinds unnamed", () => {
|
||||
const result = inspect({
|
||||
memberRefName: "Empty",
|
||||
// FIELD string
|
||||
memberRefSignature: Buffer.from([0x06, 0x0e]),
|
||||
ilBody: tokenBody([
|
||||
[ldsfld, 0x0a00_0001],
|
||||
[call, 0x0400_0001],
|
||||
]),
|
||||
});
|
||||
const methodRef = inspect({ ilBody: tokenBody([[ldsfld, 0x0a00_0001]]) });
|
||||
const flaggedField = inspect({
|
||||
memberRefName: "Empty",
|
||||
// GENERIC | FIELD string: FieldSig allows no flags.
|
||||
memberRefSignature: Buffer.from([0x16, 0x0e]),
|
||||
ilBody: tokenBody([[ldsfld, 0x0a00_0001]]),
|
||||
});
|
||||
|
||||
expect(result.field_accesses).toMatchObject([
|
||||
{ field_name: "Empty", field_declaring_type: attribute },
|
||||
]);
|
||||
expect(methodRef.field_accesses).toMatchObject([
|
||||
{ field_name: null, field_declaring_type: null },
|
||||
]);
|
||||
expect(flaggedField.field_accesses).toMatchObject([
|
||||
{ field_name: null, field_declaring_type: null },
|
||||
]);
|
||||
expect(flaggedField.coverage.state).toBe("partial");
|
||||
expect(result.call_edges).toMatchObject([
|
||||
{
|
||||
target_kind: "unknown",
|
||||
target_name: null,
|
||||
target_declaring_type: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads Evidence recorded before declaring types were named as unknown", () => {
|
||||
const current = inspect({});
|
||||
const parsed = managedMemberInspectionSchema.parse({
|
||||
...current,
|
||||
member_refs: current.member_refs.map(
|
||||
({ parent_name: _omitted, ...legacy }) => legacy,
|
||||
),
|
||||
call_edges: current.call_edges.map(
|
||||
({ target_declaring_type: _omitted, ...legacy }) => legacy,
|
||||
),
|
||||
field_accesses: current.field_accesses.map(
|
||||
({ field_declaring_type: _omitted, ...legacy }) => legacy,
|
||||
),
|
||||
});
|
||||
|
||||
expect(parsed.member_refs[0]?.parent_name).toBeNull();
|
||||
expect(parsed.call_edges[0]?.target_declaring_type).toBeNull();
|
||||
expect(parsed.field_accesses[0]?.field_declaring_type).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed TypeRef resolution scopes", () => {
|
||||
it.each([
|
||||
// AssemblyRef 9 does not exist.
|
||||
["an invalid", (9 << 2) | 2, "is invalid"],
|
||||
// The TypeRef is its own enclosing type.
|
||||
["a cyclic", (1 << 2) | 3, "contains a cycle"],
|
||||
])(
|
||||
"keeps a TypeRef name beside %s resolution scope",
|
||||
(_case, typeRefScopeRaw, detail) => {
|
||||
const result = inspect({ typeRefScopeRaw });
|
||||
|
||||
expect(result.member_refs[0]?.parent_name).toBe(attribute);
|
||||
expect(result.call_edges[0]?.target_declaring_type).toBe(attribute);
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
{
|
||||
code: "invalid-row",
|
||||
scope: "metadata.TypeRef:0x01000001",
|
||||
detail: expect.stringContaining(detail),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("managed MethodSpec call targets", () => {
|
||||
it("names generic method calls after the method they instantiate", () => {
|
||||
const result = inspect({
|
||||
memberRefSignature: genericRef,
|
||||
methodSignature: genericDef,
|
||||
methodSpecMethods: [(1 << 1) | 1, (1 << 1) | 0],
|
||||
ilBody: tokenBody([
|
||||
[call, 0x2b00_0001],
|
||||
[call, 0x2b00_0002],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.call_edges.map((edge) => [
|
||||
edge.target_kind,
|
||||
edge.target_name,
|
||||
edge.target_declaring_type,
|
||||
]),
|
||||
).toEqual([
|
||||
["method-spec", ".ctor", attribute],
|
||||
["method-spec", "Main", "Fixture.Program"],
|
||||
]);
|
||||
expect(result.coverage).toEqual({ state: "complete", issues: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a non-generic method",
|
||||
{},
|
||||
Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
"but its method declares 0 generic parameter(s)",
|
||||
],
|
||||
[
|
||||
"the wrong number of type arguments",
|
||||
{ memberRefSignature: genericRef },
|
||||
Buffer.from([0x0a, 0x02, 0x0e, 0x08]),
|
||||
"supplies 2 type argument(s), but its method declares 1",
|
||||
],
|
||||
[
|
||||
"a field reference",
|
||||
{ memberRefSignature: Buffer.from([0x06, 0x0e]) },
|
||||
Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
"does not have a decodable method signature",
|
||||
],
|
||||
[
|
||||
"a property signature",
|
||||
// PROPERTY | GENERIC, one generic parameter, no parameters, void.
|
||||
{ memberRefSignature: Buffer.from([0x18, 0x01, 0x00, 0x01]) },
|
||||
Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
"does not have a decodable method signature",
|
||||
],
|
||||
[
|
||||
"a truncated signature",
|
||||
// HASTHIS | GENERIC, one generic parameter, no parameters, no return type.
|
||||
{ memberRefSignature: Buffer.from([0x30, 0x01, 0x00]) },
|
||||
Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
"does not have a decodable method signature",
|
||||
],
|
||||
[
|
||||
"a type argument naming a missing TypeRef",
|
||||
{ memberRefSignature: genericRef },
|
||||
Buffer.from([0x0a, 0x01, 0x12, (9 << 2) | 1]),
|
||||
"TypeDefOrRefOrSpec operand 0x25 is invalid",
|
||||
],
|
||||
[
|
||||
"an unmanaged-convention signature",
|
||||
// C | GENERIC: a function pointer convention, not a MethodRefSig one.
|
||||
{ memberRefSignature: Buffer.from([0x11, 0x01, 0x00, 0x01]) },
|
||||
Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
"does not have a decodable method signature",
|
||||
],
|
||||
])(
|
||||
"reports a MethodSpec that instantiates %s",
|
||||
(_case, options, methodSpecInstantiation, detail) => {
|
||||
const result = inspect({
|
||||
...options,
|
||||
methodSpecMethods: [(1 << 1) | 1],
|
||||
methodSpecInstantiation,
|
||||
ilBody: tokenBody([[call, 0x2b00_0001]]),
|
||||
});
|
||||
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
{
|
||||
code: "invalid-row",
|
||||
scope: "metadata.MethodSpec:0x2b000001",
|
||||
detail: expect.stringContaining(detail),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("never names a field through a MethodSpec", () => {
|
||||
const result = inspect({
|
||||
memberRefName: "Empty",
|
||||
// FIELD string: a MethodSpec on it is malformed.
|
||||
memberRefSignature: Buffer.from([0x06, 0x0e]),
|
||||
methodSpecMethods: [(1 << 1) | 1],
|
||||
ilBody: tokenBody([
|
||||
[ldsfld, 0x2b00_0001],
|
||||
[call, 0x2b00_0001],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.field_accesses).toMatchObject([
|
||||
{ field_name: null, field_declaring_type: null },
|
||||
]);
|
||||
expect(result.call_edges).toMatchObject([
|
||||
{ target_name: null, target_declaring_type: null },
|
||||
]);
|
||||
expect(result.coverage.state).toBe("partial");
|
||||
});
|
||||
|
||||
it("keeps a readable MethodSpec method beside a malformed instantiation", () => {
|
||||
const result = inspect({
|
||||
methodSpecMethods: [(1 << 1) | 1, (9 << 1) | 1],
|
||||
methodSpecInstantiation: Buffer.from([0x0a, 0x00]),
|
||||
ilBody: tokenBody([
|
||||
[call, 0x2b00_0001],
|
||||
[call, 0x2b00_0002],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.call_edges.map(({ target_name, target_declaring_type }) => [
|
||||
target_name,
|
||||
target_declaring_type,
|
||||
]),
|
||||
).toEqual([
|
||||
[".ctor", attribute],
|
||||
[null, null],
|
||||
]);
|
||||
expect(result.coverage.state).toBe("partial");
|
||||
expect(
|
||||
result.coverage.issues.map(({ code, scope }) => [code, scope]),
|
||||
).toEqual([
|
||||
["invalid-blob", "metadata.MethodSpec:0x2b000001"],
|
||||
["invalid-row", "metadata.MethodSpec:0x2b000002"],
|
||||
["invalid-blob", "metadata.MethodSpec:0x2b000002"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed exported types", () => {
|
||||
it("reports type forwarders and nested exported types with their implementation scope", () => {
|
||||
const bytes = buildManagedPeFixture({
|
||||
|
|
@ -373,15 +731,9 @@ describe("managed derived edges", () => {
|
|||
body: { anchors: Array.from({ length: 40_000 }, () => anchor) },
|
||||
} as unknown as ManagedMethod;
|
||||
|
||||
expect(() =>
|
||||
edges(
|
||||
[method],
|
||||
new Map(),
|
||||
new Map(),
|
||||
new Map(),
|
||||
new ManagedDecodeBudget(),
|
||||
),
|
||||
).toThrow(ManagedReaderFailure);
|
||||
expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow(
|
||||
ManagedReaderFailure,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -413,15 +765,9 @@ describe("managed derived caller names", () => {
|
|||
},
|
||||
} as unknown as ManagedMethod;
|
||||
|
||||
expect(() =>
|
||||
edges(
|
||||
[method],
|
||||
new Map(),
|
||||
new Map(),
|
||||
new Map(),
|
||||
new ManagedDecodeBudget(),
|
||||
),
|
||||
).toThrow(ManagedReaderFailure);
|
||||
expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow(
|
||||
ManagedReaderFailure,
|
||||
);
|
||||
expect(built).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -671,6 +1017,18 @@ it.each([
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a property header", [0x08, 0x00, 0x08]],
|
||||
["an explicit-this header without has-this", [0x40, 0x00, 0x01]],
|
||||
["a C calling convention header", [0x01, 0x00, 0x01]],
|
||||
["a generic vararg header", [0x15, 0x01, 0x00, 0x01]],
|
||||
])("reports %s as a malformed member signature", (_name, blob) => {
|
||||
expect(signature(Buffer.from(blob))).toMatchObject({
|
||||
kind: "unknown",
|
||||
parse_status: "malformed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects array ranks beyond the CLR limit before rendering dimensions", () => {
|
||||
expect(
|
||||
signature(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
typeRanges,
|
||||
type TypeRange,
|
||||
} from "./ManagedMemberInspectorCore.js";
|
||||
import { edgeTargets } from "./ManagedMethodSpecs.js";
|
||||
import { typeNameLookup } from "./ManagedTypeNames.js";
|
||||
import {
|
||||
edges,
|
||||
parseExportedTypes,
|
||||
|
|
@ -171,7 +173,11 @@ export const inspectManagedMembersBytes = (
|
|||
const exportedTypes = parseExportedTypes(bytes, layout);
|
||||
issues.push(...exportedTypes.issues);
|
||||
const fields = parseFields(bytes, layout, ranges, selected);
|
||||
const memberRefs = parseMemberRefs(bytes, layout);
|
||||
const memberRefs = parseMemberRefs(
|
||||
bytes,
|
||||
layout,
|
||||
typeNameLookup(bytes, layout, ranges, issues),
|
||||
);
|
||||
issues.push(...memberRefs.issues);
|
||||
const methods = parseMethods({
|
||||
bytes,
|
||||
|
|
@ -181,6 +187,12 @@ export const inspectManagedMembersBytes = (
|
|||
selected,
|
||||
});
|
||||
issues.push(...fields.issues, ...methods.issues);
|
||||
const targets = edgeTargets(bytes, layout, methods.methods, [
|
||||
fields.core,
|
||||
memberRefs.core,
|
||||
methods.core,
|
||||
]);
|
||||
issues.push(...targets.issues);
|
||||
const coverageIssues = uniqueManagedParseIssues(issues);
|
||||
const incompleteMembers =
|
||||
[...fields.fields, ...memberRefs.refs, ...methods.methods].some(
|
||||
|
|
@ -193,9 +205,7 @@ export const inspectManagedMembersBytes = (
|
|||
);
|
||||
const related = edges(
|
||||
methods.methods,
|
||||
methods.core,
|
||||
fields.core,
|
||||
memberRefs.core,
|
||||
targets.members,
|
||||
managedDecodeBudget(layout),
|
||||
);
|
||||
return managedMemberInspectionSchema.parse(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ManagedMemberInspection,
|
||||
ManagedParseIssue,
|
||||
} from "../domain/managed/managedArtifact.js";
|
||||
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||
import type {
|
||||
ManagedMetadataLayout,
|
||||
MetadataTableLayout,
|
||||
|
|
@ -49,20 +50,22 @@ export interface TypeRange {
|
|||
readonly methodEnd: number;
|
||||
}
|
||||
|
||||
export interface MethodCore {
|
||||
/**
|
||||
* Name and declaring type of a field, method, member reference, or method
|
||||
* specification, keyed by its token so edges resolve any target uniformly.
|
||||
*/
|
||||
export interface MemberCore {
|
||||
readonly token: string;
|
||||
readonly name: string;
|
||||
readonly declaringType: string | null;
|
||||
}
|
||||
|
||||
export interface FieldCore {
|
||||
readonly token: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface MemberRefCore {
|
||||
readonly token: string;
|
||||
readonly name: string;
|
||||
/** Fields and methods by table; member references by decoded signature. */
|
||||
readonly kind: ManagedSignature["kind"];
|
||||
/**
|
||||
* Generic arity from the owner's decoded signature (see
|
||||
* `signatureGenericArity`); absent when no owner decoded it, as for a
|
||||
* MethodDef outside the type selection.
|
||||
*/
|
||||
readonly genericArity?: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedInstruction {
|
||||
|
|
@ -88,7 +91,7 @@ const rowRange = (
|
|||
return { first: start, last: end - 1, count: end - start };
|
||||
};
|
||||
|
||||
const fullName = (namespace: string, name: string): string =>
|
||||
export const fullName = (namespace: string, name: string): string =>
|
||||
namespace.length === 0 ? name : `${namespace}.${name}`;
|
||||
|
||||
const readTypeRange = (
|
||||
|
|
@ -242,7 +245,7 @@ export const parseTypes = (
|
|||
return { types: items, issues };
|
||||
};
|
||||
|
||||
const readCompressed = (
|
||||
export const readCompressed = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
): { readonly value: number; readonly next: number } => {
|
||||
|
|
@ -341,13 +344,38 @@ interface MethodSignatureHeader {
|
|||
readonly next: number;
|
||||
}
|
||||
|
||||
// MethodDefSig and MethodRefSig headers (ECMA-335 II.23.2.1-2) without
|
||||
// HASTHIS and EXPLICITTHIS: DEFAULT, VARARG, or GENERIC, which implies DEFAULT.
|
||||
const MEMBER_METHOD_HEADERS = new Set([0x00, 0x05, 0x10]);
|
||||
|
||||
// FNPTR also carries the unmanaged C, STDCALL, THISCALL, FASTCALL, and
|
||||
// UNMANAGED conventions that compilers emit for unmanaged function pointers.
|
||||
const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]);
|
||||
|
||||
/**
|
||||
* Read a method signature's calling convention and counts. A header that is
|
||||
* not a method signature in its context (a MethodDef or MemberRef row, or an
|
||||
* FNPTR type), sets the reserved bit, or sets EXPLICITTHIS without HASTHIS is
|
||||
* malformed, so no caller consumes its counts. FIELD, LOCAL_SIG, PROPERTY,
|
||||
* and GENERICINST headers are never method signatures.
|
||||
*/
|
||||
const readMethodSignatureHeader = (
|
||||
blob: Buffer,
|
||||
offset: number,
|
||||
context: "member" | "function-pointer",
|
||||
): MethodSignatureHeader => {
|
||||
const first = blob[offset];
|
||||
if (first === undefined)
|
||||
return malformedSignature("truncated method signature", offset);
|
||||
const allowed =
|
||||
context === "member"
|
||||
? MEMBER_METHOD_HEADERS.has(first & 0x9f)
|
||||
: (first & 0x80) === 0 && FUNCTION_POINTER_CONVENTIONS.has(first & 0x0f);
|
||||
if (!allowed || ((first & 0x40) !== 0 && (first & 0x20) === 0))
|
||||
return malformedSignature(
|
||||
`calling convention 0x${first.toString(16)} does not head a ${context} method signature`,
|
||||
offset,
|
||||
);
|
||||
let next = offset + 1;
|
||||
let genericParameterCount: number | null = null;
|
||||
if ((first & 0x10) !== 0) {
|
||||
|
|
@ -366,12 +394,6 @@ const readMethodSignatureHeader = (
|
|||
|
||||
const MAX_ARRAY_RANK = 32;
|
||||
|
||||
// FNPTR carries a method signature: managed DEFAULT/VARARG, or the unmanaged
|
||||
// C, STDCALL, THISCALL, FASTCALL, and UNMANAGED conventions that compilers
|
||||
// emit for unmanaged function pointers. FIELD, LOCAL_SIG, PROPERTY, and
|
||||
// GENERICINST headers are not method signatures.
|
||||
const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]);
|
||||
|
||||
const arrayShape = (
|
||||
blob: Buffer,
|
||||
offset: number,
|
||||
|
|
@ -449,10 +471,40 @@ const POSITION_ELEMENTS: ReadonlyMap<number, readonly SignaturePosition[]> =
|
|||
[0x10, ["return", "parameter", "field"]],
|
||||
]);
|
||||
|
||||
/** Decode a type operand once; spec callers also validate its metadata coordinate. */
|
||||
const readTypeOperand = (
|
||||
blob: Buffer,
|
||||
offset: number,
|
||||
layout?: ManagedMetadataLayout,
|
||||
tables: readonly number[] = [2, 1, 27],
|
||||
): ReturnType<typeof readCompressed> => {
|
||||
const token = readCompressed(blob, offset);
|
||||
if (layout !== undefined) {
|
||||
const reason =
|
||||
token.value === 0
|
||||
? "type operand is null"
|
||||
: metadataCodedTokenInvalidReason(
|
||||
token.value,
|
||||
2,
|
||||
tables,
|
||||
layout.rowCounts,
|
||||
);
|
||||
if (reason !== null)
|
||||
throw managedFailure(
|
||||
"invalid-row",
|
||||
"signature",
|
||||
`TypeDefOrRefOrSpec operand 0x${token.value.toString(16)} is invalid: ${reason}`,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
const readTypeSignature = (
|
||||
blob: Buffer,
|
||||
offset: number,
|
||||
base: SignaturePosition,
|
||||
layout?: ManagedMetadataLayout,
|
||||
): { readonly value: string; readonly next: number } => {
|
||||
const stack: TypeContinuation[] = [];
|
||||
let cursor = offset;
|
||||
|
|
@ -479,7 +531,7 @@ const readTypeSignature = (
|
|||
stack.push({ kind: "suffix", suffix });
|
||||
cursor += 1;
|
||||
} else if (kind === 0x1f || kind === 0x20) {
|
||||
const token = readCompressed(blob, cursor + 1);
|
||||
const token = readTypeOperand(blob, cursor + 1, layout);
|
||||
const modifier = `${kind === 0x1f ? "modreq" : "modopt"}:${String(token.value)}`;
|
||||
// A modifier frame on top was pushed by the directly preceding byte;
|
||||
// keep an adjacent run together and in source order.
|
||||
|
|
@ -488,7 +540,7 @@ const readTypeSignature = (
|
|||
else stack.push({ kind: "modifiers", modifiers: [modifier] });
|
||||
cursor = token.next;
|
||||
} else if (kind === 0x11 || kind === 0x12) {
|
||||
const token = readCompressed(blob, cursor + 1);
|
||||
const token = readTypeOperand(blob, cursor + 1, layout);
|
||||
value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`;
|
||||
cursor = token.next;
|
||||
} else if (kind === 0x1e || kind === 0x13) {
|
||||
|
|
@ -508,7 +560,7 @@ const readTypeSignature = (
|
|||
"generic instantiation does not name a class or value type",
|
||||
cursor + 1,
|
||||
);
|
||||
const token = readCompressed(blob, cursor + 2);
|
||||
const token = readTypeOperand(blob, cursor + 2, layout, [2, 1]);
|
||||
const argumentCount = readCompressed(blob, token.next);
|
||||
if (argumentCount.value === 0)
|
||||
return malformedSignature(
|
||||
|
|
@ -525,16 +577,11 @@ const readTypeSignature = (
|
|||
});
|
||||
cursor = argumentCount.next;
|
||||
} else if (kind === 0x1b) {
|
||||
const header = readMethodSignatureHeader(blob, cursor + 1);
|
||||
if (
|
||||
!FUNCTION_POINTER_CONVENTIONS.has(header.first & 0x0f) ||
|
||||
(header.first & 0x80) !== 0 ||
|
||||
((header.first & 0x40) !== 0 && (header.first & 0x20) === 0)
|
||||
)
|
||||
return malformedSignature(
|
||||
`function pointer calling convention 0x${header.first.toString(16)} does not head a method signature`,
|
||||
cursor + 1,
|
||||
);
|
||||
const header = readMethodSignatureHeader(
|
||||
blob,
|
||||
cursor + 1,
|
||||
"function-pointer",
|
||||
);
|
||||
const convention = callingConvention(header.first);
|
||||
const head =
|
||||
header.genericParameterCount === null
|
||||
|
|
@ -602,22 +649,81 @@ const callingConvention = (value: number): string => {
|
|||
return flags.length === 0 ? name : `${name} ${flags.join(" ")}`;
|
||||
};
|
||||
|
||||
export const signature = (
|
||||
/**
|
||||
* Decode a whole TypeSpec blob, or a MethodSpec instantiation (GENERICINST,
|
||||
* argument count, Types), with the member-signature grammar and return the
|
||||
* rendered types. A malformed or unsupported blob throws invalid-blob or
|
||||
* unsupported-signature under the owning row, so coverage records it.
|
||||
*/
|
||||
export const specSignature = (
|
||||
layout: ManagedMetadataLayout,
|
||||
blob: Buffer,
|
||||
rawSha256?: string,
|
||||
): ManagedSignature => {
|
||||
const raw = {
|
||||
raw_length: blob.length,
|
||||
raw_sha256: rawSha256 ?? sha256Bytes(blob),
|
||||
};
|
||||
owner: {
|
||||
readonly form: "type" | "instantiation";
|
||||
readonly scope: string;
|
||||
readonly offset: number;
|
||||
},
|
||||
): readonly string[] => {
|
||||
const budget = managedDecodeBudget(layout);
|
||||
// Rendering is transient, so charge it as member signatures are charged.
|
||||
budget.admitTransient(blob.length * 256, owner.offset);
|
||||
try {
|
||||
let cursor = 0;
|
||||
let count = 1;
|
||||
if (owner.form === "instantiation") {
|
||||
if (blob[0] !== 0x0a)
|
||||
malformedSignature("MethodSpec instantiation is not GENERICINST", 0);
|
||||
const header = readCompressed(blob, 1);
|
||||
if (header.value === 0)
|
||||
malformedSignature("MethodSpec instantiation has no type arguments", 1);
|
||||
count = header.value;
|
||||
cursor = header.next;
|
||||
}
|
||||
const types: string[] = [];
|
||||
// Each Type consumes at least one byte, so a false count fails on truncation.
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const type = readTypeSignature(blob, cursor, "type", layout);
|
||||
types.push(type.value);
|
||||
cursor = type.next;
|
||||
}
|
||||
if (cursor !== blob.length)
|
||||
malformedSignature("Trailing signature data", cursor);
|
||||
return types;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof ManagedReaderFailure) ||
|
||||
(cause.issue.code !== "invalid-blob" &&
|
||||
cause.issue.code !== "invalid-row" &&
|
||||
cause.issue.code !== "unsupported-signature")
|
||||
)
|
||||
throw cause;
|
||||
throw managedFailure(
|
||||
cause.issue.code,
|
||||
owner.scope,
|
||||
cause.issue.detail,
|
||||
owner.offset,
|
||||
);
|
||||
} finally {
|
||||
budget.releaseTransient();
|
||||
}
|
||||
};
|
||||
|
||||
type DecodedSignature = Omit<ManagedSignature, "raw_length" | "raw_sha256">;
|
||||
|
||||
/**
|
||||
* Decode a field or member method signature. A malformed or unsupported blob
|
||||
* decodes as kind `unknown` with its reason.
|
||||
*/
|
||||
const decodeSignature = (blob: Buffer): DecodedSignature => {
|
||||
try {
|
||||
if (blob.length === 0)
|
||||
throw managedFailure("invalid-blob", "signature", "empty signature", 0);
|
||||
const first = blob[0] ?? 0;
|
||||
if ((first & 0x0f) === 6) {
|
||||
// FieldSig (II.23.2.4) is exactly FIELD; a flagged FIELD header is not a
|
||||
// method header either, so it is malformed below.
|
||||
if (first === 0x06) {
|
||||
const fieldType = readTypeSignature(blob, 1, "field");
|
||||
return {
|
||||
...raw,
|
||||
kind: "field",
|
||||
parse_status: fieldType.next === blob.length ? "decoded" : "partial",
|
||||
calling_convention: callingConvention(first),
|
||||
|
|
@ -630,7 +736,7 @@ export const signature = (
|
|||
fieldType.next === blob.length ? null : "Trailing signature data",
|
||||
};
|
||||
}
|
||||
const header = readMethodSignatureHeader(blob, 0);
|
||||
const header = readMethodSignatureHeader(blob, 0, "member");
|
||||
const returnType = readTypeSignature(blob, header.next, "return");
|
||||
let offset = returnType.next;
|
||||
const parameters: string[] = [];
|
||||
|
|
@ -640,7 +746,6 @@ export const signature = (
|
|||
offset = parameter.next;
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
kind: "method",
|
||||
parse_status: offset === blob.length ? "decoded" : "partial",
|
||||
calling_convention: callingConvention(first),
|
||||
|
|
@ -659,7 +764,6 @@ export const signature = (
|
|||
)
|
||||
throw cause;
|
||||
return {
|
||||
...raw,
|
||||
kind: "unknown",
|
||||
parse_status:
|
||||
cause.issue.code === "unsupported-signature"
|
||||
|
|
@ -675,3 +779,41 @@ export const signature = (
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic parameter count of a method signature that decodes completely, 0
|
||||
* when it is not generic, or null when the blob is not a decodable method
|
||||
* signature. The rendered decode is transient.
|
||||
*/
|
||||
export const methodGenericArity = (
|
||||
layout: ManagedMetadataLayout,
|
||||
blob: Buffer,
|
||||
): number | null => {
|
||||
const budget = managedDecodeBudget(layout);
|
||||
budget.admitTransient(blob.length * 256, layout.blob.offset);
|
||||
try {
|
||||
return signatureGenericArity(decodeSignature(blob));
|
||||
} finally {
|
||||
budget.releaseTransient();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic parameter count of a completely decoded method signature, 0 when it
|
||||
* is not generic, or null for any other signature.
|
||||
*/
|
||||
export const signatureGenericArity = (
|
||||
decoded: DecodedSignature,
|
||||
): number | null =>
|
||||
decoded.kind === "method" && decoded.parse_status === "decoded"
|
||||
? (decoded.generic_parameter_count ?? 0)
|
||||
: null;
|
||||
|
||||
export const signature = (
|
||||
blob: Buffer,
|
||||
rawSha256?: string,
|
||||
): ManagedSignature => ({
|
||||
raw_length: blob.length,
|
||||
raw_sha256: rawSha256 ?? sha256Bytes(blob),
|
||||
...decodeSignature(blob),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const PREFIX_OPCODES = new Set([
|
|||
"tail.",
|
||||
"constrained.",
|
||||
"readonly.",
|
||||
"no.",
|
||||
]);
|
||||
|
||||
const OPCODES = new Map<
|
||||
|
|
@ -232,6 +233,7 @@ const OPCODES = new Map<
|
|||
[0xfe16, { name: "constrained.", operand: "type" }],
|
||||
[0xfe17, { name: "cpblk", operand: "none" }],
|
||||
[0xfe18, { name: "initblk", operand: "none" }],
|
||||
[0xfe19, { name: "no.", operand: "short-i" }],
|
||||
[0xfe1a, { name: "rethrow", operand: "none" }],
|
||||
[0xfe1c, { name: "sizeof", operand: "type" }],
|
||||
[0xfe1d, { name: "refanytype", operand: "none" }],
|
||||
|
|
@ -384,6 +386,22 @@ const readOperand = (
|
|||
return { next: offset, anchorKind: "unknown", value: null };
|
||||
};
|
||||
|
||||
/** An operand cut off by the end of the IL body has no decodable value. */
|
||||
const readOperandWithin = (
|
||||
il: Buffer,
|
||||
offset: number,
|
||||
kind: string,
|
||||
): OperandResult | undefined => {
|
||||
try {
|
||||
return readOperand(il, offset, kind);
|
||||
} catch (cause: unknown) {
|
||||
// Buffer reads past the IL body throw RangeError; report it as the
|
||||
// instruction leaving the method body rather than as a Buffer message.
|
||||
if (cause instanceof RangeError) return undefined;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const decodeInstructions = (
|
||||
il: Buffer,
|
||||
admitInstruction?: (offset: number) => void,
|
||||
|
|
@ -404,14 +422,22 @@ export const decodeInstructions = (
|
|||
const start = offset;
|
||||
const first = il.readUInt8(offset);
|
||||
offset += 1;
|
||||
if (first === 0xfe && offset >= il.length) {
|
||||
issue = `Two-byte CIL opcode at IL offset ${String(start)} leaves method body`;
|
||||
break;
|
||||
}
|
||||
const code = first === 0xfe ? 0xfe00 + il.readUInt8(offset++) : first;
|
||||
const descriptor = OPCODES.get(code);
|
||||
if (descriptor === undefined) {
|
||||
issue = `Unsupported CIL opcode 0x${code.toString(16)} at IL offset ${String(start)}`;
|
||||
break;
|
||||
}
|
||||
const operand = readOperand(il, offset, descriptor.operand);
|
||||
if (operand.next < offset || operand.next > il.length) {
|
||||
const operand = readOperandWithin(il, offset, descriptor.operand);
|
||||
if (
|
||||
operand === undefined ||
|
||||
operand.next < offset ||
|
||||
operand.next > il.length
|
||||
) {
|
||||
issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,14 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
|||
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
signature,
|
||||
type FieldCore,
|
||||
signatureGenericArity,
|
||||
type ManagedCallEdge,
|
||||
type ManagedExportedType,
|
||||
type ManagedField,
|
||||
type ManagedFieldAccess,
|
||||
type ManagedMemberRef,
|
||||
type ManagedMethod,
|
||||
type MemberRefCore,
|
||||
type MethodCore,
|
||||
type MemberCore,
|
||||
type TypeRange,
|
||||
} from "./ManagedMemberInspectorCore.js";
|
||||
import {
|
||||
|
|
@ -51,11 +50,11 @@ export const parseFields = (
|
|||
selected: SelectedTypeTokens = null,
|
||||
): {
|
||||
readonly fields: readonly ManagedField[];
|
||||
readonly core: ReadonlyMap<string, FieldCore>;
|
||||
readonly core: ReadonlyMap<string, MemberCore>;
|
||||
readonly issues: readonly ManagedParseIssue[];
|
||||
} => {
|
||||
const fields: ManagedField[] = [];
|
||||
const core = new Map<string, FieldCore>();
|
||||
const core = new Map<string, MemberCore>();
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
const table = layout.table(4);
|
||||
const ownerRow = readManagedValue(
|
||||
|
|
@ -78,7 +77,12 @@ export const parseFields = (
|
|||
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
|
||||
const declared = ranges[owner - 1];
|
||||
const token = metadataToken(4, row);
|
||||
core.set(token, { token, name });
|
||||
core.set(token, {
|
||||
token,
|
||||
name,
|
||||
declaringType: declared?.fullName ?? null,
|
||||
kind: "field",
|
||||
});
|
||||
if (!isSelected(selected, declared)) return;
|
||||
const sig = readMetadataBlob(
|
||||
bytes,
|
||||
|
|
@ -174,13 +178,14 @@ export const parseExportedTypes = (
|
|||
export const parseMemberRefs = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
typeName: (token: string | null) => string | null,
|
||||
): {
|
||||
readonly refs: readonly ManagedMemberRef[];
|
||||
readonly core: ReadonlyMap<string, MemberRefCore>;
|
||||
readonly core: ReadonlyMap<string, MemberCore>;
|
||||
readonly issues: readonly ManagedParseIssue[];
|
||||
} => {
|
||||
const refs: ManagedMemberRef[] = [];
|
||||
const core = new Map<string, MemberRefCore>();
|
||||
const core = new Map<string, MemberCore>();
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
const table = layout.table(10);
|
||||
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
|
||||
|
|
@ -218,19 +223,29 @@ export const parseMemberRefs = (
|
|||
? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row"
|
||||
: `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`,
|
||||
});
|
||||
const parentToken = metadataCodedToken(
|
||||
parentRaw,
|
||||
3,
|
||||
[2, 1, 26, 6, 27],
|
||||
layout.rowCounts,
|
||||
);
|
||||
const parentName = typeName(parentToken);
|
||||
const decoded = budgetedSignature(sig, layout);
|
||||
refs.push({
|
||||
token,
|
||||
row_offset: cursor.start,
|
||||
parent_token: metadataCodedToken(
|
||||
parentRaw,
|
||||
3,
|
||||
[2, 1, 26, 6, 27],
|
||||
layout.rowCounts,
|
||||
),
|
||||
parent_token: parentToken,
|
||||
parent_name: parentName,
|
||||
name,
|
||||
signature: budgetedSignature(sig, layout),
|
||||
signature: decoded,
|
||||
});
|
||||
core.set(token, {
|
||||
token,
|
||||
name,
|
||||
declaringType: parentName,
|
||||
kind: decoded.kind,
|
||||
genericArity: signatureGenericArity(decoded),
|
||||
});
|
||||
core.set(token, { token, name });
|
||||
}, issues);
|
||||
}
|
||||
return { refs, core, issues };
|
||||
|
|
@ -252,11 +267,11 @@ export const parseMethods = ({
|
|||
selected = null,
|
||||
}: ParseMethodsInput): {
|
||||
readonly methods: readonly ManagedMethod[];
|
||||
readonly core: ReadonlyMap<string, MethodCore>;
|
||||
readonly core: ReadonlyMap<string, MemberCore>;
|
||||
readonly issues: readonly ManagedParseIssue[];
|
||||
} => {
|
||||
const methods: ManagedMethod[] = [];
|
||||
const core = new Map<string, MethodCore>();
|
||||
const core = new Map<string, MemberCore>();
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
const table = layout.table(6);
|
||||
const ownerRow = readManagedValue(
|
||||
|
|
@ -281,11 +296,13 @@ export const parseMethods = ({
|
|||
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
|
||||
const declared = ranges[owner - 1];
|
||||
const token = metadataToken(6, row);
|
||||
core.set(token, {
|
||||
const member = {
|
||||
token,
|
||||
name,
|
||||
declaringType: declared?.fullName ?? null,
|
||||
});
|
||||
kind: "method",
|
||||
} as const;
|
||||
core.set(token, member);
|
||||
if (!isSelected(selected, declared)) return;
|
||||
const sig = readMetadataBlob(
|
||||
bytes,
|
||||
|
|
@ -294,6 +311,11 @@ export const parseMethods = ({
|
|||
layout.blob.size,
|
||||
);
|
||||
cursor.readIndex(layout.tableIndexSize(8));
|
||||
const decoded = budgetedSignature(sig, layout);
|
||||
core.set(token, {
|
||||
...member,
|
||||
genericArity: signatureGenericArity(decoded),
|
||||
});
|
||||
methods.push({
|
||||
token,
|
||||
row_offset: cursor.start,
|
||||
|
|
@ -303,7 +325,7 @@ export const parseMethods = ({
|
|||
rva,
|
||||
impl_flags: implFlags,
|
||||
flags,
|
||||
signature: budgetedSignature(sig, layout),
|
||||
signature: decoded,
|
||||
body: methodBody(
|
||||
bytes,
|
||||
pe,
|
||||
|
|
@ -317,6 +339,15 @@ export const parseMethods = ({
|
|||
return { methods, core, issues };
|
||||
};
|
||||
|
||||
/**
|
||||
* Member kinds each operand kind may name. A MemberRef whose signature does
|
||||
* not decode keeps its name on calls, as before, but is never named a field.
|
||||
*/
|
||||
const NAMEABLE_KINDS = new Map<string, ReadonlySet<MemberCore["kind"]>>([
|
||||
["method", new Set(["method", "unknown"])],
|
||||
["field", new Set(["field"])],
|
||||
]);
|
||||
|
||||
const targetKind = (token: string): ManagedCallEdge["target_kind"] =>
|
||||
token.startsWith("0x06")
|
||||
? "method-def"
|
||||
|
|
@ -326,11 +357,22 @@ const targetKind = (token: string): ManagedCallEdge["target_kind"] =>
|
|||
? "method-spec"
|
||||
: "unknown";
|
||||
|
||||
/** The member an operand names, if the operand kind can name that member's kind. */
|
||||
const namedTarget = (
|
||||
members: ReadonlyMap<string, MemberCore>,
|
||||
operandKind: string,
|
||||
operand: string,
|
||||
): MemberCore | undefined => {
|
||||
const member = members.get(operand);
|
||||
return member !== undefined &&
|
||||
NAMEABLE_KINDS.get(operandKind)?.has(member.kind) === true
|
||||
? member
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const edges = (
|
||||
methods: readonly ManagedMethod[],
|
||||
methodCore: ReadonlyMap<string, MethodCore>,
|
||||
fieldCore: ReadonlyMap<string, FieldCore>,
|
||||
refCore: ReadonlyMap<string, MemberRefCore>,
|
||||
members: ReadonlyMap<string, MemberCore>,
|
||||
budget: ManagedDecodeBudget,
|
||||
): {
|
||||
readonly callEdges: readonly ManagedCallEdge[];
|
||||
|
|
@ -350,11 +392,10 @@ export const edges = (
|
|||
const caller = derives ? callerName(method) : method.name;
|
||||
for (const anchor of method.body.anchors) {
|
||||
if (anchor.opcode === "ldtoken") continue;
|
||||
if (anchor.operand_kind === "method" && anchor.operand !== null) {
|
||||
const named =
|
||||
methodCore.get(anchor.operand)?.name ??
|
||||
refCore.get(anchor.operand)?.name ??
|
||||
null;
|
||||
const { operand } = anchor;
|
||||
if (operand === null) continue;
|
||||
const target = namedTarget(members, anchor.operand_kind, operand);
|
||||
if (anchor.operand_kind === "method") {
|
||||
// Each derived record is admitted before the graph grows further.
|
||||
callEdges.push(
|
||||
admitManagedProjection(
|
||||
|
|
@ -362,23 +403,25 @@ export const edges = (
|
|||
caller_token: method.token,
|
||||
caller,
|
||||
opcode: anchor.opcode,
|
||||
target_token: anchor.operand,
|
||||
target_kind: targetKind(anchor.operand),
|
||||
target_name: named,
|
||||
target_token: operand,
|
||||
target_kind: targetKind(operand),
|
||||
target_name: target?.name ?? null,
|
||||
target_declaring_type: target?.declaringType ?? null,
|
||||
},
|
||||
budget,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (anchor.operand_kind === "field" && anchor.operand !== null) {
|
||||
if (anchor.operand_kind === "field") {
|
||||
fieldAccesses.push(
|
||||
admitManagedProjection(
|
||||
{
|
||||
method_token: method.token,
|
||||
method: caller,
|
||||
opcode: anchor.opcode,
|
||||
field_token: anchor.operand,
|
||||
field_name: fieldCore.get(anchor.operand)?.name ?? null,
|
||||
field_token: operand,
|
||||
field_name: target?.name ?? null,
|
||||
field_declaring_type: target?.declaringType ?? null,
|
||||
},
|
||||
budget,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
||||
import {
|
||||
methodGenericArity,
|
||||
specSignature,
|
||||
type ManagedMethod,
|
||||
type MemberCore,
|
||||
} from "./ManagedMemberInspectorCore.js";
|
||||
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
metadataCodedToken,
|
||||
metadataCodedTokenInvalidReason,
|
||||
metadataRowCursor,
|
||||
readMetadataBlob,
|
||||
} from "./ManagedMetadataHeaps.js";
|
||||
import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js";
|
||||
|
||||
/**
|
||||
* Index fields, member references, and methods by token, then name each
|
||||
* MethodSpec an inspected body calls after the generic method it instantiates,
|
||||
* so every edge target resolves through one lookup.
|
||||
*/
|
||||
export const edgeTargets = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
methods: readonly ManagedMethod[],
|
||||
cores: readonly ReadonlyMap<string, MemberCore>[],
|
||||
): {
|
||||
readonly members: ReadonlyMap<string, MemberCore>;
|
||||
readonly issues: readonly ManagedParseIssue[];
|
||||
} => {
|
||||
const members = new Map(cores.flatMap((core) => [...core]));
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Generic arity by method token, so instantiations of one method decode it once.
|
||||
const arities = new Map<string, number | null>();
|
||||
for (const method of methods)
|
||||
for (const { operand } of method.body.anchors) {
|
||||
if (operand?.startsWith("0x2b") !== true || seen.has(operand)) continue;
|
||||
seen.add(operand);
|
||||
readManagedValue(
|
||||
() =>
|
||||
readMethodSpec({ bytes, layout, members, issues, arities }, operand),
|
||||
issues,
|
||||
);
|
||||
}
|
||||
return { members, issues };
|
||||
};
|
||||
|
||||
/**
|
||||
* Map one MethodSpec to its generic method, then check its instantiation
|
||||
* decodes and matches that method's generic arity. A readable Method column
|
||||
* keeps its name when the rest is malformed; the failure makes coverage partial.
|
||||
*/
|
||||
const readMethodSpec = (
|
||||
{
|
||||
bytes,
|
||||
layout,
|
||||
members,
|
||||
issues,
|
||||
arities,
|
||||
}: {
|
||||
readonly bytes: Buffer;
|
||||
readonly layout: ManagedMetadataLayout;
|
||||
readonly members: Map<string, MemberCore>;
|
||||
readonly issues: ManagedParseIssue[];
|
||||
readonly arities: Map<string, number | null>;
|
||||
},
|
||||
token: string,
|
||||
): void => {
|
||||
const scope = `metadata.MethodSpec:${token}`;
|
||||
const cursor = metadataRowCursor(
|
||||
bytes,
|
||||
layout,
|
||||
0x2b,
|
||||
Number.parseInt(token.slice(4), 16),
|
||||
);
|
||||
const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef"));
|
||||
const instantiation = cursor.readIndex(layout.blobIndexSize);
|
||||
const methodReason = metadataCodedTokenInvalidReason(
|
||||
methodRaw,
|
||||
1,
|
||||
[6, 10],
|
||||
layout.rowCounts,
|
||||
);
|
||||
if (methodReason !== null || methodRaw === 0)
|
||||
issues.push({
|
||||
code: "invalid-row",
|
||||
scope,
|
||||
offset: cursor.start,
|
||||
detail:
|
||||
methodReason === null
|
||||
? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row"
|
||||
: `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${methodReason}`,
|
||||
});
|
||||
const generic = metadataCodedToken(methodRaw, 1, [6, 10], layout.rowCounts);
|
||||
const target = generic === null ? undefined : members.get(generic);
|
||||
// A MethodSpec instantiates a method, so a field target never lends it a
|
||||
// name, even to a field operand.
|
||||
if (target !== undefined && target.kind !== "field")
|
||||
members.set(token, { ...target, token });
|
||||
const types = specSignature(
|
||||
layout,
|
||||
readMetadataBlob(bytes, layout, instantiation, layout.blob.size),
|
||||
{ form: "instantiation", scope, offset: cursor.start },
|
||||
);
|
||||
if (generic === null) return;
|
||||
// Prefer the arity the target's owner decoded; read the raw signature only
|
||||
// for a target nothing decoded, such as a MethodDef outside the selection.
|
||||
if (target?.genericArity === undefined && !arities.has(generic))
|
||||
arities.set(
|
||||
generic,
|
||||
methodGenericArity(layout, methodSignatureBlob(bytes, layout, generic)),
|
||||
);
|
||||
const arity =
|
||||
target?.genericArity === undefined
|
||||
? (arities.get(generic) ?? null)
|
||||
: target.genericArity;
|
||||
if (arity !== types.length)
|
||||
throw managedFailure(
|
||||
"invalid-row",
|
||||
scope,
|
||||
arity === null
|
||||
? "MethodSpec method does not have a decodable method signature"
|
||||
: `MethodSpec supplies ${String(types.length)} type argument(s), but its method declares ${String(arity)} generic parameter(s)`,
|
||||
cursor.start,
|
||||
);
|
||||
};
|
||||
|
||||
/** The Signature column of a MethodDef or MemberRef row. */
|
||||
const methodSignatureBlob = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
token: string,
|
||||
): Buffer => {
|
||||
const table = Number.parseInt(token.slice(2, 4), 16);
|
||||
const cursor = metadataRowCursor(
|
||||
bytes,
|
||||
layout,
|
||||
table,
|
||||
Number.parseInt(token.slice(4), 16),
|
||||
);
|
||||
if (table === 6) {
|
||||
cursor.readUInt32();
|
||||
cursor.readUInt16();
|
||||
cursor.readUInt16();
|
||||
} else cursor.readIndex(layout.codedIndexSize("MemberRefParent"));
|
||||
cursor.readIndex(layout.stringIndexSize);
|
||||
return readMetadataBlob(
|
||||
bytes,
|
||||
layout,
|
||||
cursor.readIndex(layout.blobIndexSize),
|
||||
layout.blob.size,
|
||||
);
|
||||
};
|
||||
|
|
@ -29,6 +29,16 @@ interface ManagedPeFixtureOptions {
|
|||
readonly moduleRowCount?: number;
|
||||
readonly assemblyRowCount?: number;
|
||||
readonly memberRefParentRaw?: number;
|
||||
readonly memberRefName?: string;
|
||||
readonly memberRefSignature?: Buffer;
|
||||
/** Raw MethodDefOrRef coded values, one MethodSpec row each. */
|
||||
readonly methodSpecMethods?: readonly number[];
|
||||
/** Instantiation blob shared by every MethodSpec row. */
|
||||
readonly methodSpecInstantiation?: Buffer;
|
||||
/** TypeSpec signature blobs, one TypeSpec row each. */
|
||||
readonly typeSpecSignatures?: readonly Buffer[];
|
||||
/** Raw ResolutionScope coded value of the TypeRef row; null by default. */
|
||||
readonly typeRefScopeRaw?: number;
|
||||
readonly mvid?: Buffer;
|
||||
readonly pinvoke?: {
|
||||
readonly importName?: string;
|
||||
|
|
@ -174,10 +184,10 @@ const typeRefRow = (
|
|||
name: number,
|
||||
namespace: number,
|
||||
stringSize: MetadataIndexSize,
|
||||
resolutionScopeSize: MetadataIndexSize,
|
||||
resolutionScope: Buffer,
|
||||
): Buffer =>
|
||||
Buffer.concat([
|
||||
metadataIndex(0, resolutionScopeSize),
|
||||
resolutionScope,
|
||||
metadataIndex(name, stringSize),
|
||||
metadataIndex(namespace, stringSize),
|
||||
]);
|
||||
|
|
@ -524,6 +534,8 @@ interface FixtureMetadataRowContext {
|
|||
readonly methodSignature: number;
|
||||
readonly constructorSignature: number;
|
||||
readonly attributeBlob: number;
|
||||
readonly typeSpecSignatures: readonly number[];
|
||||
readonly methodSpecInstantiation: number;
|
||||
};
|
||||
readonly heapSizes: {
|
||||
readonly strings: MetadataIndexSize;
|
||||
|
|
@ -595,6 +607,37 @@ const exportedTypeTable = (
|
|||
],
|
||||
];
|
||||
|
||||
const specTables = (
|
||||
options: ManagedPeFixtureOptions,
|
||||
indexes: FixtureMetadataRowContext["indexes"],
|
||||
heapSizes: FixtureMetadataRowContext["heapSizes"],
|
||||
widths: FixtureIndexWidths,
|
||||
): readonly (readonly [number, readonly Buffer[]])[] => [
|
||||
...(indexes.typeSpecSignatures.length === 0
|
||||
? []
|
||||
: ([
|
||||
[
|
||||
27,
|
||||
indexes.typeSpecSignatures.map((signature) =>
|
||||
metadataIndex(signature, heapSizes.blobs),
|
||||
),
|
||||
],
|
||||
] as const)),
|
||||
...(options.methodSpecMethods === undefined
|
||||
? []
|
||||
: ([
|
||||
[
|
||||
43,
|
||||
options.methodSpecMethods.map((method) =>
|
||||
Buffer.concat([
|
||||
metadataIndex(method, widths.coded([6, 10], 1)),
|
||||
metadataIndex(indexes.methodSpecInstantiation, heapSizes.blobs),
|
||||
]),
|
||||
),
|
||||
],
|
||||
] as const)),
|
||||
];
|
||||
|
||||
const fixtureIndexWidths = ({
|
||||
options,
|
||||
referenceStringIndexes,
|
||||
|
|
@ -616,10 +659,12 @@ const fixtureIndexWidths = ({
|
|||
],
|
||||
...(indexes.pinvokeModuleName === null ? [] : ([[26, 1]] as const)),
|
||||
...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)),
|
||||
[27, indexes.typeSpecSignatures.length],
|
||||
[32, options.assemblyRowCount ?? 1],
|
||||
[35, referenceStringIndexes.length],
|
||||
[39, indexes.exportedTypes.length],
|
||||
[40, 1],
|
||||
[43, options.methodSpecMethods?.length ?? 0],
|
||||
]);
|
||||
const count = (table: number): number => rowCounts.get(table) ?? 0;
|
||||
const table = (index: number): MetadataIndexSize => indexSize(count(index));
|
||||
|
|
@ -673,7 +718,7 @@ const fixtureMetadataRows = ({
|
|||
indexes.attributeName,
|
||||
indexes.attributeNamespace,
|
||||
heapSizes.strings,
|
||||
widths.resolutionScope,
|
||||
metadataIndex(options.typeRefScopeRaw ?? 0, widths.resolutionScope),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
@ -780,6 +825,7 @@ const fixtureMetadataRows = ({
|
|||
),
|
||||
],
|
||||
...exportedTypeTable(options, indexes, heapSizes, widths),
|
||||
...specTables(options, indexes, heapSizes, widths),
|
||||
[
|
||||
40,
|
||||
[
|
||||
|
|
@ -795,6 +841,29 @@ const fixtureMetadataRows = ({
|
|||
return rows;
|
||||
};
|
||||
|
||||
/** Blob indexes for the MemberRef and optional spec rows; defaults leave the heap unchanged. */
|
||||
const referenceBlobIndexes = (
|
||||
blobs: BlobHeap,
|
||||
options: ManagedPeFixtureOptions,
|
||||
): Pick<
|
||||
FixtureMetadataRowContext["indexes"],
|
||||
"constructorSignature" | "typeSpecSignatures" | "methodSpecInstantiation"
|
||||
> => ({
|
||||
constructorSignature: blobs.add(
|
||||
options.memberRefSignature ?? Buffer.from([0x20, 0x01, 0x01, 0x0e]),
|
||||
),
|
||||
typeSpecSignatures: (options.typeSpecSignatures ?? []).map((signature) =>
|
||||
blobs.add(signature),
|
||||
),
|
||||
// Defaults to GENERICINST with one string type argument.
|
||||
methodSpecInstantiation:
|
||||
options.methodSpecMethods === undefined
|
||||
? 0
|
||||
: blobs.add(
|
||||
options.methodSpecInstantiation ?? Buffer.from([0x0a, 0x01, 0x0e]),
|
||||
),
|
||||
});
|
||||
|
||||
const buildManagedFixtureMetadata = (
|
||||
options: ManagedPeFixtureOptions,
|
||||
methodRva: number,
|
||||
|
|
@ -805,7 +874,7 @@ const buildManagedFixtureMetadata = (
|
|||
const assemblyName = strings.add("Fixture.Managed");
|
||||
const attributeName = strings.add("TargetFrameworkAttribute");
|
||||
const attributeNamespace = strings.add("System.Runtime.Versioning");
|
||||
const constructorName = strings.add(".ctor");
|
||||
const constructorName = strings.add(options.memberRefName ?? ".ctor");
|
||||
const typeName = strings.add(options.typeName ?? "Program");
|
||||
const additionalTypeNames = Array.from(
|
||||
{ length: options.additionalEmptyTypes ?? 0 },
|
||||
|
|
@ -840,7 +909,7 @@ const buildManagedFixtureMetadata = (
|
|||
const methodSignature = blobs.add(
|
||||
options.methodSignature ?? Buffer.from([0x00, 0x00, 0x01]),
|
||||
);
|
||||
const constructorSignature = blobs.add(Buffer.from([0x20, 0x01, 0x01, 0x0e]));
|
||||
const referenceBlobs = referenceBlobIndexes(blobs, options);
|
||||
const attributeBlob = blobs.add(
|
||||
fixedStringAttributeBlob(
|
||||
options.targetFramework === null
|
||||
|
|
@ -873,8 +942,8 @@ const buildManagedFixtureMetadata = (
|
|||
tokenBlob,
|
||||
fieldSignature,
|
||||
methodSignature,
|
||||
constructorSignature,
|
||||
attributeBlob,
|
||||
...referenceBlobs,
|
||||
},
|
||||
heapSizes: { strings: stringSize, blobs: blobSize, guids: guidSize },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
|
||||
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
|
||||
import {
|
||||
fullName,
|
||||
readCompressed,
|
||||
specSignature,
|
||||
type TypeRange,
|
||||
} from "./ManagedMemberInspectorCore.js";
|
||||
import {
|
||||
metadataRowOffset,
|
||||
type ManagedMetadataLayout,
|
||||
} from "./ManagedMetadataLayout.js";
|
||||
import {
|
||||
metadataCodedTokenInvalidReason,
|
||||
metadataRowCursor,
|
||||
metadataToken,
|
||||
readMetadataBlob,
|
||||
readMetadataString,
|
||||
} from "./ManagedMetadataHeaps.js";
|
||||
import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js";
|
||||
|
||||
/**
|
||||
* Name the type a TypeDef, TypeRef, or generic-instantiation TypeSpec token
|
||||
* denotes, in the namespace-qualified form TypeDef `full_name` uses, once per
|
||||
* token. Other decoded TypeSpecs stay null: the type is unknown. Malformed or
|
||||
* unsupported rows and blobs are reported to `issues` and also stay null,
|
||||
* except that a TypeRef with an invalid ResolutionScope keeps its readable name.
|
||||
*/
|
||||
export const typeNameLookup = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
ranges: readonly TypeRange[],
|
||||
issues: ManagedParseIssue[],
|
||||
): ((token: string | null) => string | null) => {
|
||||
const names = new Map<string, string | null>();
|
||||
const checkedScopes = new Set<number>();
|
||||
const validateScopeChain = (row: number): void => {
|
||||
const path = new Set<number>();
|
||||
let current = row;
|
||||
while (!checkedScopes.has(current)) {
|
||||
const cursor = metadataRowCursor(bytes, layout, 1, current);
|
||||
if (path.has(current)) {
|
||||
issues.push({
|
||||
code: "invalid-row",
|
||||
scope: `metadata.TypeRef:${metadataToken(1, current)}`,
|
||||
offset: cursor.start,
|
||||
detail: "TypeRef ResolutionScope chain contains a cycle",
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Admit the chain's visited entry and retained validation-cache entry.
|
||||
managedDecodeBudget(layout).reserve(128, cursor.start);
|
||||
path.add(current);
|
||||
const raw = cursor.readIndex(layout.codedIndexSize("ResolutionScope"));
|
||||
const reason = metadataCodedTokenInvalidReason(
|
||||
raw,
|
||||
2,
|
||||
[0, 26, 35, 1],
|
||||
layout.rowCounts,
|
||||
);
|
||||
if (reason !== null) {
|
||||
issues.push({
|
||||
code: "invalid-row",
|
||||
scope: `metadata.TypeRef:${metadataToken(1, current)}`,
|
||||
offset: cursor.start,
|
||||
detail: `TypeRef ResolutionScope coded index 0x${raw.toString(16)} is invalid: ${reason}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Null scopes are legal; non-TypeRef scopes terminate local validation.
|
||||
if (raw === 0 || (raw & 3) !== 3) break;
|
||||
current = Math.floor(raw / 4);
|
||||
}
|
||||
for (const visited of path) checkedScopes.add(visited);
|
||||
};
|
||||
const definitionName = (table: 1 | 2, row: number): string | null => {
|
||||
if (table === 2) {
|
||||
metadataRowOffset(layout, 2, row);
|
||||
return ranges[row - 1]?.fullName ?? null;
|
||||
}
|
||||
const cursor = metadataRowCursor(bytes, layout, 1, row);
|
||||
cursor.readIndex(layout.codedIndexSize("ResolutionScope"));
|
||||
readManagedValue(() => validateScopeChain(row), issues);
|
||||
const name = cursor.readIndex(layout.stringIndexSize);
|
||||
const namespace = cursor.readIndex(layout.stringIndexSize);
|
||||
return fullName(
|
||||
readMetadataString(bytes, layout, namespace, layout.strings.size),
|
||||
readMetadataString(bytes, layout, name, layout.strings.size),
|
||||
);
|
||||
};
|
||||
const specName = (row: number): string | null => {
|
||||
const cursor = metadataRowCursor(bytes, layout, 27, row);
|
||||
const spec = readMetadataBlob(
|
||||
bytes,
|
||||
layout,
|
||||
cursor.readIndex(layout.blobIndexSize),
|
||||
layout.blob.size,
|
||||
);
|
||||
const owner = {
|
||||
form: "type",
|
||||
scope: `metadata.TypeSpec:${metadataToken(27, row)}`,
|
||||
offset: cursor.start,
|
||||
} as const;
|
||||
specSignature(layout, spec, owner);
|
||||
if (spec[0] !== 0x15) return null;
|
||||
// GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded: never a TypeSpec,
|
||||
// so naming never recurses.
|
||||
const encoded = readCompressed(spec, 2).value;
|
||||
const table = ([2, 1] as const)[encoded & 3];
|
||||
if (table === undefined)
|
||||
throw managedFailure(
|
||||
"invalid-blob",
|
||||
owner.scope,
|
||||
"Generic instantiation does not name a TypeDef or TypeRef",
|
||||
owner.offset,
|
||||
);
|
||||
return definitionName(table, Math.floor(encoded / 4));
|
||||
};
|
||||
return (token) => {
|
||||
if (token === null) return null;
|
||||
if (names.has(token)) return names.get(token) ?? null;
|
||||
const table = Number.parseInt(token.slice(2, 4), 16);
|
||||
const row = Number.parseInt(token.slice(4), 16);
|
||||
const name =
|
||||
readManagedValue(
|
||||
() =>
|
||||
table === 27
|
||||
? specName(row)
|
||||
: table === 1 || table === 2
|
||||
? definitionName(table, row)
|
||||
: null,
|
||||
issues,
|
||||
) ?? null;
|
||||
names.set(token, name);
|
||||
return name;
|
||||
};
|
||||
};
|
||||
|
|
@ -127,7 +127,7 @@ export const resolveGhidraAnalysisProfile = async (
|
|||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_references: "exact-address-incoming-v1",
|
||||
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
|
||||
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
|
||||
no_return_repair:
|
||||
installation.platform === "win32"
|
||||
? "disabled-windows-p0"
|
||||
|
|
@ -136,7 +136,7 @@ export const resolveGhidraAnalysisProfile = async (
|
|||
installation.platform === "win32"
|
||||
? "official-headless-script-v1"
|
||||
: "inspected-jvm-launch-support-v1",
|
||||
load_image_evidence: "independent-raw-mapping-context-v2",
|
||||
load_image_evidence: "independent-raw-mapping-context-v4",
|
||||
jump_table_evidence: "typed-case-default-blocks-v1",
|
||||
decompiler_jump_loads: true,
|
||||
loader: "BinaryLoader",
|
||||
|
|
@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async (
|
|||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_references: "exact-address-incoming-v1",
|
||||
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
|
||||
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
|
||||
no_return_repair:
|
||||
installation.platform === "win32"
|
||||
? "disabled-windows-p0"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue