chore(docs): merge captured main into canonical guide layout

This commit is contained in:
morluto 2026-10-13 21:44:34 +08:00
commit 0b46590f8f
164 changed files with 6831 additions and 859 deletions

View File

@ -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.

View File

@ -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

View File

@ -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 }}

View File

@ -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/

View File

@ -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 }}

View File

@ -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: |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: .

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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 {

View File

@ -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"
);

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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`,

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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()

View File

@ -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",

View File

@ -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,

View File

@ -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;
}

View File

@ -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",

View File

@ -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",

View File

@ -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, {

View File

@ -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) =>

View File

@ -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>();

View File

@ -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);
}

View File

@ -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 });
};

View File

@ -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;

View File

@ -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,

View File

@ -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")

View File

@ -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,

View File

@ -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,

View File

@ -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([

View File

@ -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[],

View File

@ -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;

View File

@ -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 = (

View File

@ -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(

View File

@ -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(

View File

@ -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 = {

View File

@ -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,

View File

@ -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"],

View File

@ -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,
},
}),
},
};
};

View File

@ -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({

View File

@ -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}`,

View File

@ -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,
};
};

View File

@ -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";

View File

@ -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;

View File

@ -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",

View File

@ -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;

View File

@ -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,
},
}),
],
),
),

View File

@ -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,
});
});
});

View File

@ -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,
);
};

View File

@ -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 &&

View File

@ -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 = (

View File

@ -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,
);
};

View File

@ -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({

View File

@ -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,

View File

@ -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({

View File

@ -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")

View File

@ -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({

View File

@ -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: [

View File

@ -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");
});
});

View File

@ -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(

View File

@ -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(

View File

@ -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),
});

View File

@ -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;
}

View File

@ -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,
),

View File

@ -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,
);
};

View File

@ -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 },
});

View File

@ -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;
};
};

View File

@ -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