chore(plugins): sync the captured final review base
This commit is contained in:
commit
961f410094
|
|
@ -73,7 +73,7 @@ rea setup --client codex --dry-run --json
|
|||
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
|
||||
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
|
||||
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
|
||||
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
|
||||
For unsupported clients, use version-pinned manual stdio registration or CLI.
|
||||
|
||||
|
|
|
|||
|
|
@ -224,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
|||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
that address. Hopper and IDA refuse this interpretation; Ghidra uses
|
||||
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
|
||||
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/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/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ runs:
|
|||
steps:
|
||||
- name: Download portable build outputs from this workflow run
|
||||
if: inputs.build_cache_artifact != ''
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: ${{ inputs.build_cache_artifact }}
|
||||
path: .turbo/cache/
|
||||
|
|
@ -39,14 +39,14 @@ runs:
|
|||
' >> "$GITHUB_OUTPUT"
|
||||
- name: Restore portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
restore-keys: ${{ steps.plan.outputs.prefix }}
|
||||
- name: Restore and save portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
steps:
|
||||
# Consume only API data and execute batch code from the trusted default branch.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Record queue, execution, and matched step timing
|
||||
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
- name: Upload CI timing artifacts
|
||||
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ci-job-timings-batch
|
||||
path: .cache/ci-job-timings/
|
||||
|
|
|
|||
|
|
@ -63,11 +63,11 @@ jobs:
|
|||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
test_selection: ${{ steps.plan.outputs.test_selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Bind qualification to the caller revision
|
||||
|
|
@ -118,8 +118,8 @@ jobs:
|
|||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
run: npm run format:check
|
||||
- name: Upload complete static diagnostics
|
||||
if: always() && needs.changes.outputs.code == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: static-check-reports
|
||||
path: .cache/static-checks/
|
||||
|
|
@ -157,13 +157,13 @@ jobs:
|
|||
matrix:
|
||||
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
|
|
@ -177,7 +177,7 @@ jobs:
|
|||
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: vitest-report-${{ strategy.job-index }}
|
||||
|
|
@ -208,8 +208,8 @@ jobs:
|
|||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -231,13 +231,13 @@ jobs:
|
|||
- name: Download previous timing report
|
||||
if: steps.history.outputs.run-id != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: test-timings
|
||||
run-id: ${{ steps.history.outputs.run-id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: .cache/ci-test-history
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: |
|
||||
|
|
@ -254,7 +254,7 @@ jobs:
|
|||
# test:prepare caches only portable JS, skill, and catalog outputs.
|
||||
# Native package jobs still run their build and prepack checks; Turbo
|
||||
# restores matching outputs or rebuilds when their inputs differ.
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
|
|
@ -280,16 +280,16 @@ jobs:
|
|||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- if: needs.changes.outputs.code == 'true' && success()
|
||||
run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
path: .vitest/blob
|
||||
|
|
@ -330,7 +330,7 @@ jobs:
|
|||
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: node scripts/ci/test-report.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
with:
|
||||
name: test-results
|
||||
|
|
@ -345,7 +345,7 @@ jobs:
|
|||
retention-days: 30
|
||||
- name: Publish successful timing history
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-timings
|
||||
path: .cache/ci-test-history/test-results.json
|
||||
|
|
@ -366,7 +366,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Verify fixtures from a clean checkout
|
||||
run: npm run verify:fixtures
|
||||
|
||||
|
|
@ -377,8 +377,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
|
|
@ -395,12 +395,12 @@ jobs:
|
|||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- run: node scripts/build-windows-native.mjs --inspection-fixture
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -423,8 +423,8 @@ jobs:
|
|||
NODE_OPTIONS: "--max-old-space-size=1024"
|
||||
UV_THREADPOOL_SIZE: "2"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -438,11 +438,11 @@ jobs:
|
|||
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
|
||||
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -463,7 +463,7 @@ jobs:
|
|||
run: npm run verify:inspector
|
||||
- name: Retain the exact qualified release package
|
||||
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
|
|
@ -479,13 +479,13 @@ jobs:
|
|||
runs-on: macos-15
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
|
|
@ -527,8 +527,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -539,7 +539,7 @@ jobs:
|
|||
run: |
|
||||
set -o pipefail
|
||||
npm run verify:readiness | tee readiness-summary.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: reconstruction-readiness
|
||||
|
|
@ -556,18 +556,18 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- run: npm run build:cached
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
|
|
@ -597,6 +597,7 @@ jobs:
|
|||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/filesystem/windowsNpxRegistration.test.ts
|
||||
tests/acceptance/setup/homeDirectoryFallback.test.ts
|
||||
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||
tests/boundary/process/ProcessEvidence.host.test.ts
|
||||
tests/acceptance/webModuleCli.test.ts
|
||||
|
|
@ -622,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/
|
||||
|
|
@ -641,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
|
||||
|
|
@ -651,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
|
||||
|
|
@ -665,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
|
||||
|
|
@ -674,7 +675,7 @@ jobs:
|
|||
- run: npm run docs:check
|
||||
- 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: |
|
||||
|
|
@ -781,8 +782,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
|
||||
|
|
@ -793,7 +794,7 @@ jobs:
|
|||
run: node scripts/ci/required.mjs
|
||||
- name: Upload full qualification receipt artifact
|
||||
if: needs.changes.outputs.tier == 'full' && success()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ steps.gate.outputs.receipt_name }}
|
||||
path: ${{ steps.gate.outputs.receipt_path }}
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ jobs:
|
|||
DOTNET_PROCESSOR_COUNT: "1"
|
||||
COMPlus_GCHeapHardLimit: "30000000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: "8.0.416"
|
||||
- name: Build benign fixture and independent byte oracle
|
||||
run: node scripts/generate-nativeaot-fixtures.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ jobs:
|
|||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
|
|
@ -45,12 +45,12 @@ jobs:
|
|||
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
env:
|
||||
REA_ANALYSIS_PROVIDER: ghidra
|
||||
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-ghidra-proof
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
@ -36,8 +36,8 @@ jobs:
|
|||
runs-on: [self-hosted, Linux, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ jobs:
|
|||
runs-on: [self-hosted, macOS, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Download the pinned Termux app and verify its release checksum
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Build and capture a page inside Termux
|
||||
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: 30
|
||||
arch: x86_64
|
||||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
script: bash scripts/verify/termux/emulator.sh
|
||||
- name: Upload Termux and Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: termux-browser-diagnostics
|
||||
path: ${{ runner.temp }}/rea-termux/diagnostics
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ jobs:
|
|||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
echo "frozen=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
if: steps.editorial.outputs.frozen != 'true'
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -70,13 +70,13 @@ jobs:
|
|||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- name: Check out reviewed release merge
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -104,7 +104,7 @@ jobs:
|
|||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -191,14 +191,14 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Check out release controller and Git history
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for checkpoint validation
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -226,7 +226,7 @@ jobs:
|
|||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
# A dedicated credential starts PR CI automatically; the built-in
|
||||
|
|
@ -255,7 +255,7 @@ jobs:
|
|||
|
||||
- name: Check out release pull request
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
fetch-depth: 0
|
||||
|
|
@ -263,7 +263,7 @@ jobs:
|
|||
|
||||
- name: Set up Node.js for generated documentation
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -314,12 +314,12 @@ jobs:
|
|||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
|
@ -328,7 +328,7 @@ jobs:
|
|||
- run: npm ci
|
||||
|
||||
- name: Download the exact qualified package
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
|
|
@ -392,7 +392,7 @@ jobs:
|
|||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
|
|
|||
|
|
@ -22,14 +22,14 @@ jobs:
|
|||
# The repository-pinned Node 24 version also runs the complete test suite.
|
||||
node: ["22.x", "24.15.0", "24.x", "26.x"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
check-latest: true
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
|
@ -74,9 +74,9 @@ jobs:
|
|||
)
|
||||
PY
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
- name: Upload the website
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
with:
|
||||
path: website/_site
|
||||
|
||||
|
|
@ -95,15 +95,15 @@ jobs:
|
|||
steps:
|
||||
- name: Check out publishing configuration
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Node.js for Wrangler
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Download the verified website
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: github-pages
|
||||
path: ${{ runner.temp }}/website-archive
|
||||
|
|
@ -114,7 +114,7 @@ jobs:
|
|||
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
|
||||
- name: Publish the website to Cloudflare
|
||||
if: inputs.target == 'both'
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
|
@ -123,7 +123,7 @@ jobs:
|
|||
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
|
||||
- name: Publish to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
- name: Verify the selected published revisions
|
||||
env:
|
||||
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack
|
|||
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
|
||||
protocols in adapters and shared CLI/MCP workflows in the application layer.
|
||||
|
||||
### Windows source checkouts
|
||||
|
||||
The published npm package includes the Windows x64 native addon and its
|
||||
manifest. A source checkout does not: `native/windows/build/` is ignored by
|
||||
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
|
||||
produces `rea-windows-x64.node` or `manifest.json` in that directory.
|
||||
Windows workflows and tests that require native controls need these artifacts;
|
||||
without them, those controls are unavailable even after a successful runtime build.
|
||||
|
||||
Follow the [Windows native build guide](native/windows/README.md#build) before
|
||||
running those workflows from source. The artifact build requires Linux,
|
||||
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
|
||||
for the build and artifact-verification commands and the separate real Windows
|
||||
verification prerequisites. Keep the generated addon and manifest uncommitted.
|
||||
|
||||
## Documentation website
|
||||
|
||||
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import java.net.UnixDomainSocketAddress;
|
|||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
|
|
@ -22,7 +24,9 @@ import java.nio.file.StandardCopyOption;
|
|||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
|
|
@ -82,6 +86,7 @@ import ghidra.program.model.address.SegmentedAddress;
|
|||
import ghidra.program.model.reloc.Relocation;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import ghidra.program.model.symbol.Namespace;
|
||||
import ghidra.program.model.symbol.FlowType;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceIterator;
|
||||
|
|
@ -100,6 +105,9 @@ import ghidra.program.model.pcode.PcodeBlock;
|
|||
import ghidra.program.model.pcode.PcodeBlockBasic;
|
||||
import ghidra.program.model.pcode.Varnode;
|
||||
import ghidra.program.model.pcode.VarnodeAST;
|
||||
import ghidra.program.util.ContextEvaluatorAdapter;
|
||||
import ghidra.program.util.SymbolicPropogator;
|
||||
import ghidra.program.util.VarnodeContext;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int MAX_HIGH_PCODE_OPS = 3000;
|
||||
|
|
@ -804,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"));
|
||||
|
|
@ -817,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 {
|
||||
|
|
@ -1319,6 +1332,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
result.add("outgoing_references", outgoing);
|
||||
result.add("referenced_strings", referencedStrings);
|
||||
result.add("referenced_names", referencedNames);
|
||||
boolean swift = swiftProgram();
|
||||
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
|
||||
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
|
||||
if (swiftLiterals != null) {
|
||||
result.add("swift_string_literals", swiftLiterals.literals());
|
||||
result.add("swift_string_literal_omissions", swiftLiterals.omissions());
|
||||
}
|
||||
result.add("basic_blocks", blocks);
|
||||
result.add(
|
||||
"native_api",
|
||||
|
|
@ -1338,6 +1358,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
limitations.add(
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
|
||||
);
|
||||
if (swift && aarch64) {
|
||||
limitations.add(
|
||||
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
|
||||
);
|
||||
if (swiftLiterals.omissions().size() > 0) {
|
||||
limitations.add(
|
||||
"Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined."
|
||||
);
|
||||
}
|
||||
} else if (swift) {
|
||||
limitations.add(
|
||||
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
|
||||
);
|
||||
}
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) {
|
||||
limitations.add(limitation);
|
||||
|
|
@ -3391,6 +3425,349 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
return result;
|
||||
}
|
||||
|
||||
private boolean swiftProgram() {
|
||||
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
|
||||
String name = block.getName();
|
||||
if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swift passes a String as two words: small literals carry their bytes in the words and
|
||||
* immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps
|
||||
* neither as a data reference, so decode constant pairs where Swift consumes them: argument
|
||||
* and return registers at calls, tail calls and returns, and both registers of one STP.
|
||||
* The canonical empty string is a small literal with a zero count.
|
||||
*/
|
||||
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
|
||||
Register[] registers = new Register[31];
|
||||
for (int index = 0; index < registers.length; index++) {
|
||||
registers[index] = currentProgram.getRegister("x" + index);
|
||||
}
|
||||
TreeMap<String, JsonObject> observed = new TreeMap<>();
|
||||
TreeMap<String, JsonObject> omitted = new TreeMap<>();
|
||||
SwiftLiteralBudget budget = new SwiftLiteralBudget();
|
||||
Map<String, Boolean> selectedValues = new HashMap<>();
|
||||
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
|
||||
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
|
||||
@Override
|
||||
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
|
||||
// Returning true ends only the current flow, so every queued flow checks the shared stop.
|
||||
if (budget.stopped()) return true;
|
||||
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
|
||||
Long word = registerConstant(context, pair[0]);
|
||||
Long object = registerConstant(context, pair[1]);
|
||||
if (word == null || object == null) continue;
|
||||
SwiftLiteralLayout layout = swiftLiteralLayout(word, object);
|
||||
if (layout == null) continue;
|
||||
// A selected word can combine with a path-dependent ordinary move. Cache the
|
||||
// provenance question per site/register, charging retained cache state to the
|
||||
// same dossier budget before allocating it.
|
||||
boolean selected = false;
|
||||
boolean provenanceOverCapacity = false;
|
||||
for (Register register : pair) {
|
||||
String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" +
|
||||
register.getBaseRegister().getName();
|
||||
Boolean value = selectedValues.get(provenanceKey);
|
||||
if (value == null) {
|
||||
if (!budget.takeState()) {
|
||||
provenanceOverCapacity = true;
|
||||
break;
|
||||
}
|
||||
value = selectedValue(function, instruction, register);
|
||||
selectedValues.put(provenanceKey, value);
|
||||
}
|
||||
if (value) {
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected) continue;
|
||||
// Forks can reach one site with different constants, so the words are part of the key.
|
||||
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() +
|
||||
"\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) +
|
||||
"\u0000" + Long.toUnsignedString(object, 16);
|
||||
if (observed.containsKey(key) || omitted.containsKey(key)) continue;
|
||||
// Every record draws on the budget; the first one that does not fit is kept as an
|
||||
// omission and decoding stops, so later sites are never examined.
|
||||
String value = layout.smallValue();
|
||||
boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength());
|
||||
if (!overCapacity && value == null) {
|
||||
try {
|
||||
value = swiftLargeLiteralText(layout);
|
||||
} catch (OutOfMemoryError exhausted) {
|
||||
overCapacity = true;
|
||||
}
|
||||
}
|
||||
if (value == null && !overCapacity) continue;
|
||||
JsonObject item = overCapacity
|
||||
? swiftStringOmission(layout, word, object)
|
||||
: swiftStringItem(value, layout, word, object);
|
||||
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
|
||||
JsonArray pairRegisters = new JsonArray();
|
||||
pairRegisters.add(pair[0].getName());
|
||||
pairRegisters.add(pair[1].getName());
|
||||
item.add("registers", pairRegisters);
|
||||
(overCapacity ? omitted : observed).put(key, item);
|
||||
if (overCapacity) {
|
||||
budget.stop();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, false, monitor);
|
||||
JsonArray literals = new JsonArray();
|
||||
observed.values().forEach(literals::add);
|
||||
JsonArray omissions = new JsonArray();
|
||||
omitted.values().forEach(omissions::add);
|
||||
return new SwiftStringLiteralDecode(literals, omissions);
|
||||
}
|
||||
|
||||
private static List<Register[]> swiftStringPairs(
|
||||
Function function,
|
||||
Instruction instruction,
|
||||
Register[] registers
|
||||
) {
|
||||
List<Register[]> pairs = new ArrayList<>();
|
||||
if (instruction.getMnemonicString().equals("stp")) {
|
||||
Register first = instruction.getRegister(0);
|
||||
Register second = instruction.getRegister(1);
|
||||
if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) {
|
||||
pairs.add(new Register[] {first, second});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
FlowType flow = instruction.getFlowType();
|
||||
boolean returns = false;
|
||||
for (PcodeOp operation : instruction.getPcode()) {
|
||||
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
|
||||
}
|
||||
// A jump is a proven tail call only when it has resolved targets and all leave the
|
||||
// body. A targetless computed jump can still dispatch inside the function.
|
||||
Address[] targets = instruction.getFlows();
|
||||
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
|
||||
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
|
||||
if (!flow.isCall() && !returns && !tailCall) return pairs;
|
||||
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
|
||||
int lastFirstRegister = returns ? 0 : 6;
|
||||
for (int index = 0; index <= lastFirstRegister; index++) {
|
||||
pairs.add(new Register[] {registers[index], registers[index + 1]});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static Long registerConstant(VarnodeContext context, Register register) {
|
||||
Varnode value = context.getRegisterVarnodeValue(register);
|
||||
return value != null && context.isConstant(value) ? value.getOffset() : null;
|
||||
}
|
||||
|
||||
// Search every predecessor path for the reaching definitions, following register copies,
|
||||
// since a select copied into an argument register or defined before a branch forks just as
|
||||
// independently as one written there directly.
|
||||
private boolean selectedValue(Function function, Instruction use, Register register) {
|
||||
ArrayDeque<Instruction> instructions = new ArrayDeque<>();
|
||||
ArrayDeque<Register> targets = new ArrayDeque<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
for (Instruction predecessor : predecessors(function, use)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(register.getBaseRegister());
|
||||
}
|
||||
while (!instructions.isEmpty()) {
|
||||
Instruction instruction = instructions.pop();
|
||||
Register target = targets.pop();
|
||||
if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue;
|
||||
boolean defines = false;
|
||||
for (Object result : instruction.getResultObjects()) {
|
||||
if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true;
|
||||
}
|
||||
Register next = target;
|
||||
if (defines) {
|
||||
PcodeOp[] operations = instruction.getPcode();
|
||||
for (PcodeOp operation : operations) {
|
||||
if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true;
|
||||
}
|
||||
Register source = copiedRegister(operations);
|
||||
if (source == null) continue;
|
||||
next = source.getBaseRegister();
|
||||
}
|
||||
for (Instruction predecessor : predecessors(function, instruction)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Instruction> predecessors(Function function, Instruction instruction) {
|
||||
List<Instruction> result = new ArrayList<>();
|
||||
Address from = instruction.getFallFrom();
|
||||
if (from != null) {
|
||||
Instruction previous = currentProgram.getListing().getInstructionAt(from);
|
||||
if (previous != null) result.add(previous);
|
||||
}
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) {
|
||||
if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue;
|
||||
if (!function.getBody().contains(reference.getFromAddress())) continue;
|
||||
Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress());
|
||||
if (source != null) result.add(source);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Register copiedRegister(PcodeOp[] operations) {
|
||||
if (operations.length != 1) return null;
|
||||
int opcode = operations[0].getOpcode();
|
||||
Varnode input = operations[0].getInput(0);
|
||||
if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null;
|
||||
return currentProgram.getRegister(input);
|
||||
}
|
||||
|
||||
/** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */
|
||||
private SwiftLiteralLayout swiftLiteralLayout(long word, long object) {
|
||||
int discriminator = (int) (object >>> 60);
|
||||
if (discriminator == 0xE || discriminator == 0xA) {
|
||||
int count = (int) ((object >>> 56) & 0xF);
|
||||
byte[] bytes = new byte[15];
|
||||
for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index));
|
||||
for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index));
|
||||
for (int index = count; index < bytes.length; index++) {
|
||||
if (bytes[index] != 0) return null;
|
||||
}
|
||||
// Count zero is the canonical empty string (object 0xE000000000000000, word 0).
|
||||
String value = swiftStringText(bytes, count, discriminator == 0xE);
|
||||
return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value);
|
||||
}
|
||||
int flags = (int) (word >>> 60);
|
||||
long count = word & 0xFFFFFFFFFFFFL;
|
||||
// Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32.
|
||||
if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20);
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
// The bytes and their terminator must lie in one initialized, non-executable block,
|
||||
// and Java arrays bound one read.
|
||||
if (block == null || !block.isInitialized() || block.isExecute() ||
|
||||
count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return new SwiftLiteralLayout("large", address, count, flags == 0xD, null);
|
||||
} catch (Exception unaddressable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String swiftLargeLiteralText(SwiftLiteralLayout layout) {
|
||||
int count = (int) layout.byteLength();
|
||||
byte[] bytes = new byte[count + 1];
|
||||
try {
|
||||
if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null;
|
||||
} catch (Exception unreadable) {
|
||||
return null;
|
||||
}
|
||||
return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal record of one dossier draws on half of the heap that was free when decoding
|
||||
* began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up
|
||||
* to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer
|
||||
* (up to twice its length), again in the returned String, and once more as UTF-8 on the
|
||||
* socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words
|
||||
* and their serialized form, so many empty or small literals are bounded too. Retained
|
||||
* provenance-cache entries draw on that same fixed-state allowance.
|
||||
*/
|
||||
private static final class SwiftLiteralBudget {
|
||||
private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6;
|
||||
private static final long BYTES_PER_RECORD = 4096;
|
||||
private long remaining;
|
||||
private boolean stopped;
|
||||
|
||||
SwiftLiteralBudget() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
|
||||
}
|
||||
|
||||
boolean takeState() {
|
||||
if (remaining < BYTES_PER_RECORD) return false;
|
||||
remaining -= BYTES_PER_RECORD;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean take(long bytes) {
|
||||
if (remaining < BYTES_PER_RECORD ||
|
||||
bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) {
|
||||
return false;
|
||||
}
|
||||
remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE;
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
boolean stopped() {
|
||||
return stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Swift String values may contain any scalar, including controls; JSON escapes them.
|
||||
private static String swiftStringText(byte[] bytes, int length, boolean ascii) {
|
||||
if (length < 0 || length > bytes.length) return null;
|
||||
for (int index = 0; index < length; index++) {
|
||||
if (ascii && bytes[index] < 0) return null;
|
||||
}
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString();
|
||||
} catch (CharacterCodingException invalid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.addProperty("byte_length", layout.byteLength());
|
||||
item.addProperty("reason", "capacity");
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringItem(
|
||||
String value,
|
||||
SwiftLiteralLayout layout,
|
||||
long word,
|
||||
long object
|
||||
) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("value", value);
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonArray swiftStringWords(long word, long object) {
|
||||
JsonArray words = new JsonArray();
|
||||
words.add("0x" + Long.toUnsignedString(word, 16));
|
||||
words.add("0x" + Long.toUnsignedString(object, 16));
|
||||
return words;
|
||||
}
|
||||
|
||||
private static String exclusiveAddress(Address inclusive) {
|
||||
Address next = inclusive.next();
|
||||
if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) {
|
||||
|
|
@ -3930,6 +4307,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||
private record AddressNameRequest(String inputAddress, Address address, String name) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
private record SwiftLiteralLayout(
|
||||
String representation,
|
||||
Address address,
|
||||
long byteLength,
|
||||
boolean ascii,
|
||||
String smallValue
|
||||
) {}
|
||||
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
|
||||
private record SessionDescriptor(
|
||||
String transport,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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](mcp-contracts.md#tool-results).
|
||||
|
|
|
|||
|
|
@ -80,6 +80,27 @@ google-chrome \
|
|||
|
||||
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
|
||||
|
||||
Other Chromium-family browsers, such as Chromium or Brave, work the same way
|
||||
with the same two flags. On macOS, run the executable inside the application
|
||||
bundle, for example
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs
|
||||
Chromium as `chromium-browser`.
|
||||
|
||||
REA needs the browser's HTTP discovery endpoint on a loopback port. A browser
|
||||
driven over `--remote-debugging-pipe` or a private channel, for example by an
|
||||
automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing
|
||||
to attach to. Connection failures report `endpoint_unreachable`; a reachable
|
||||
HTTP service that returns a non-200 or non-JSON discovery response reports
|
||||
`invalid_endpoint_response`.
|
||||
Reproduce that flow in a Chromium-family browser started with
|
||||
`--remote-debugging-port`.
|
||||
|
||||
In a minimal Linux container, headed Chromium under Xvfb can keep running while
|
||||
its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries
|
||||
(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then
|
||||
restart the browser.
|
||||
|
||||
On Linux, a reachable but locked desktop keyring can stall Chrome before its
|
||||
first navigation. For a dedicated investigation profile, add
|
||||
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
|
||||
|
|
@ -207,7 +228,7 @@ commands fail closed. A correlated reply's session ID must match the command's
|
|||
selected session. An optional method is unavailable only when the browser
|
||||
rejects it as unknown; other command rejections retain their reported failure.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
|
|||
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
|
||||
```
|
||||
|
||||
An exact CLI cache hit is read before starting a provider process.
|
||||
An exact CLI cache hit is normally read before starting a provider process.
|
||||
On Windows, Ghidra first admits the selected source through its native snapshot
|
||||
owner, so replay can start that provider.
|
||||
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
|
||||
for its matching target; an MCP provider may still start before returning a
|
||||
cached result. `close_binary` accepts `snapshot_path` and optional
|
||||
|
|
|
|||
|
|
@ -165,6 +165,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
|
|||
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
|
||||
installs its skill copy there.
|
||||
|
||||
## Factory Droid
|
||||
|
||||
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
|
||||
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
|
||||
`command`, `args`, and `env`. Droid discovers personal skills from
|
||||
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
|
||||
setup installs its skill copy there.
|
||||
|
||||
## Trae
|
||||
|
||||
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
|
||||
|
|
@ -209,8 +217,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
|
|||
patches and comments. `rea doctor --client dsh` checks that same entry id.
|
||||
`rea uninstall` removes it by scanning supported clients; uninstall takes no
|
||||
`--client` flag. The home patch applies to every Harness profile, so the
|
||||
registration is written once and no per-profile edit is needed. The written
|
||||
entry is:
|
||||
registration is written once and no per-profile edit is needed.
|
||||
|
||||
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
|
||||
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
|
||||
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
|
||||
values resolve against the command's working directory. Nonblank values retain
|
||||
their original spaces, and the agents root is independent of the Harness home.
|
||||
Run REA and Harness from the same working directory when using relative overrides.
|
||||
The written entry is:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
|
|||
interpretation changes invalidate profile-bound snapshots. The support-lane
|
||||
identity is versioned independently of file identity. Evidence, lifecycle
|
||||
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
|
||||
|
||||
### Soft-float prototype
|
||||
|
||||
|
|
|
|||
|
|
@ -131,9 +131,10 @@ overview merely because its source artifact is unchanged.
|
|||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
|
||||
observed terminal call sites in Evidence limitations, including resolved callee
|
||||
names, addresses, and FunctionManager flags when available. Instruction windows
|
||||
Pseudocode, assembly, procedure info, instruction windows, callee and caller
|
||||
reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
including resolved callee names, addresses, and FunctionManager flags when
|
||||
available. Instruction windows
|
||||
and dossiers also retain these warnings in their result limitations. An
|
||||
unresolved callee or unestablished terminal-flow basis remains explicit. These
|
||||
observations qualify the analysis; they do not prove that a no-return flag is
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
|
|||
target also retains the raw ELF declarations that decide a lane: class, byte
|
||||
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
|
||||
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
|
||||
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
|
||||
its existing four families.
|
||||
are unchanged. Headerless inputs use the separate [raw-binary admission
|
||||
boundary](raw-binary.md); raw RISC-V images are not supported.
|
||||
|
||||
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
|
||||
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ REA copies the admitted binary into a private temporary directory, checks its di
|
|||
|
||||
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
|
||||
|
||||
When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol.
|
||||
|
||||
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
|
||||
|
||||
## Connect an agent once
|
||||
|
|
@ -126,6 +128,8 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
|
|||
|
||||
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
|
||||
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
|
||||
|
||||
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
|
||||
|
||||
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
|
||||
|
|
|
|||
|
|
@ -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](windows-ghidra-p0.md) for verified scope and
|
||||
|
|
|
|||
|
|
@ -208,6 +208,25 @@ 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
|
||||
result in an alias does not make its old field values immutable. Known array
|
||||
indices include holes; an element after a spread has only a minimum possible
|
||||
index. Unrelated properties and known array prefixes remain precise, and a
|
||||
getter read without a write does not invalidate the receiver's fields.
|
||||
Nested callable returns are not assigned to their parent callable. Projected
|
||||
graph observations carry source ranges but never source text.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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](windows-ghidra-p0.md) and
|
||||
x86 and x86-64 PE applications and DLLs, plus explicitly selected
|
||||
[raw NMOS 6502 images](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](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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -682,7 +682,9 @@ use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
|
|||
The lane invokes the production CLI dispatcher and connects the pinned MCP
|
||||
client SDK to the production REA server. It verifies function Evidence and
|
||||
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
|
||||
input, original-input preservation, and lifecycle cleanup. For headless
|
||||
input, original-input preservation, and lifecycle cleanup. The headless
|
||||
profile also checks containing procedures, the admitted program as the
|
||||
document, address context, and feature tracing. For headless
|
||||
analysis it confirms that the owned database IDs disappear from upstream
|
||||
discovery and private workspaces are removed. For attached analysis it confirms
|
||||
the existing GUI target remains reachable with the same input identity.
|
||||
|
|
@ -732,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,
|
||||
|
|
|
|||
|
|
@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
|
|||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
|
||||
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
|
||||
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
|
||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||
|
||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||
PE load-image inspection returns an explicit unsupported attestation status.
|
||||
Independent verification covers admitted DOS MZ, explicit COM, and explicit
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
|
||||
extend Windows P0 admission beyond native PE. For x64 PE
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts, including the
|
||||
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
|
|
@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
|
|||
operations remain unavailable through this provider. Managed PE/CLI inspection
|
||||
uses its separate execution-free provider.
|
||||
|
||||
Raw 6502 imports retain the same native filesystem and process ownership
|
||||
requirements. The complete image must fit in the 16-bit address space; no
|
||||
console-specific cartridge loader, CPU variant, or bank-switching model is
|
||||
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
|
||||
to check the source-built fixture through real CLI and MCP workflows.
|
||||
|
||||
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
|
||||
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
|
||||
PyGhidra are not prerequisites. Users do not build the native addon.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ def main():
|
|||
if undeclared:
|
||||
raise ValueError('Declare inherited provider settings explicitly in --providers: '
|
||||
+ ', '.join(undeclared) + '. Values are not logged.')
|
||||
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
|
||||
else Path.home() / '.codex')
|
||||
source_auth = source_auth_home / 'auth.json'
|
||||
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
runtime_files, runtime_digest = runtime_fingerprint(repository)
|
||||
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
|
||||
|
|
@ -103,7 +106,6 @@ def main():
|
|||
workspace = profile / 'workspace'
|
||||
for directory in [trial_codex_home, account, workspace]:
|
||||
directory.mkdir(mode=0o700)
|
||||
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
|
||||
auth = trial_codex_home / 'auth.json'
|
||||
started = time.monotonic()
|
||||
timeout = False
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
|
|||
self.assertNotIn(marker, text)
|
||||
self.assertIn('caller-selected evidence and paths', text)
|
||||
self.assertIn('original-response', text)
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
# Windows mode bits do not describe access control; this is a POSIX assertion.
|
||||
if os.name == 'posix':
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
|
|||
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
|
||||
patch.object(agent.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
|
||||
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
|
||||
agent.main()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
# Windows reconciliation of recorded POSIX paths
|
||||
|
||||
Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including
|
||||
@Aurantium-47's diagnosis of the host-dependent relative-path operation.
|
||||
|
||||
## Source pins and real data
|
||||
|
||||
The initial reproduction and passive producer used main
|
||||
fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated
|
||||
to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its
|
||||
affected reconciliation consumers were unchanged between those revisions.
|
||||
The newer baseline again reproduced both errors on the real Windows host.
|
||||
|
||||
Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes,
|
||||
SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526.
|
||||
The installed copy was independently checked against the exact module in the
|
||||
[official registry package](https://registry.npmjs.org/npm/11.16.0).
|
||||
The 2,945,850-byte tarball matched its published SHA-512 SRI before reading
|
||||
that member; no archive extraction or installation occurred, and the tarball
|
||||
was not retained.
|
||||
|
||||
Two actual Linux filesystem directories, Runtime/App and Runtime/app,
|
||||
held byte-identical module copies and had distinct directory identities.
|
||||
A controlled Node 24.18.0 process loaded the public module and ran its real
|
||||
function against 587 locked dependency records: compatible trees returned zero
|
||||
errors, and one controlled version mismatch returned one error. A passive REA
|
||||
Inspector capture recorded 114 loaded scripts, including the selected module.
|
||||
The provider did not evaluate, pause, resume or read source. The owned process
|
||||
exited normally. Function execution is established by the harness receipt;
|
||||
REA's passive script metadata establishes loaded status.
|
||||
|
||||
Production static analysis inspected both directories plus an independent
|
||||
Static/public-module copy. The latter prevents implicit root matching from
|
||||
hiding the explicit mapping result. Complete static/runtime Evidence was then
|
||||
consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using
|
||||
Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0.
|
||||
No Electron GUI, IDA or Ghidra engine was used.
|
||||
|
||||
## Correctness and provenance
|
||||
|
||||
| Windows reconciliation | Baseline | Candidate |
|
||||
| -------------------------------------- | ------------------------------ | ----------------------------------- |
|
||||
| Exact POSIX input root | Matched, artifact path | Same complete normalized result |
|
||||
| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result |
|
||||
| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
|
||||
All four CLI and same-connection MCP normalized results agree. The input
|
||||
Evidence, recorded runtime facts and caller-selected root spellings are
|
||||
preserved. Rejected location membership does not establish that a module was
|
||||
absent or unexecuted. The operation remains offline and performs no target or
|
||||
filesystem inspection.
|
||||
|
||||
The shared matcher now chooses posix.relative after identifying POSIX input;
|
||||
Windows coordinates continue to use win32.relative. Both location matching
|
||||
and active-layer scope use this owner. The obsolete host-default import is
|
||||
removed; no downstream repair or new path abstraction is introduced.
|
||||
|
||||
The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and
|
||||
two curated-path checks. Four simulated Electron Evidence controls cover
|
||||
POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting
|
||||
asset load state and retained mapping spelling. They are service regressions,
|
||||
not real Electron-provider verification. The existing workflow test file is
|
||||
registered in both the Windows execution lane and its package-change scope.
|
||||
|
||||
## Delivery and resource observations
|
||||
|
||||
Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap
|
||||
settings for CLI/server/client work. Heap settings do not bound aggregate memory.
|
||||
Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and
|
||||
322,308–324,280 KiB peak working set, excluding the probe parent. Wall times
|
||||
ranged from 7.785 to 17.601 seconds. There is no paired resource baseline.
|
||||
|
||||
One complete MCP trial connected in 18,296 ms and discovered 189 tools in
|
||||
875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls
|
||||
took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 /
|
||||
918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these
|
||||
inputs and replies totalled 6,934,288 bytes, excluding initialization, framing
|
||||
and notifications. This is delivery volume, not model token usage. Actual
|
||||
model context and total model task cost were not measured.
|
||||
|
||||
A process-generation sampler recorded 233 observations over 25,671 ms:
|
||||
15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set
|
||||
sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed;
|
||||
shared pages can be counted twice. This excludes the observer and is not an
|
||||
exact physical-memory peak. The server wrote four structured completion records
|
||||
to stderr; tool replies and normalized results passed the recorded checks.
|
||||
|
||||
Cold/warm caches and observer differences were not normalized. No latency,
|
||||
CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O,
|
||||
engine invocation, retry or cache. Complete default replies remain available;
|
||||
only compact selected findings and hashes were sent to the investigating agent.
|
||||
|
||||
## Main integration replay
|
||||
|
||||
Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main
|
||||
73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP
|
||||
cases using the unchanged older producer Evidence. Positive results and original
|
||||
runtime/layer facts remain identical. Typecheck, lint, dependency and metadata
|
||||
checks passed; all five changed files passed formatting independently.
|
||||
The full format gate at that main revision failed on the unchanged upstream
|
||||
GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not
|
||||
repair those unrelated baseline files. Resource figures above refer to the
|
||||
earlier candidate; the integration replay does not establish a speedup.
|
||||
|
||||
Review found that owning only the Windows test path would still skip that lane
|
||||
on a later implementation-only edit. The matcher implementation is now also
|
||||
registered in package scope. A source-only classifier case verifies that it
|
||||
selects both Windows package and JavaScript recovery lanes; 85 scope checks and
|
||||
two curated-path checks pass. Runtime code and the real-data checks are unchanged.
|
||||
|
||||
The original production fix merged as
|
||||
[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up
|
||||
was open. The follow-up was then rebased onto main fb28ec153 and contains only
|
||||
the stronger workflow assertions, Windows lane ownership, scope classification
|
||||
and this verification record. The updated 131 related tests, two representative
|
||||
Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure
|
||||
tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI
|
||||
tests passed after the rebase (230 total).
|
||||
Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated
|
||||
duplicated helpers; e05d3399f aligned the older parameter-provenance assertion
|
||||
with the upstream semantics. Those changes, the threshold change and the new
|
||||
CI qualification tiers are part of main, outside this follow-up's diff.
|
||||
Upstream 747e72991 restored release preflight coverage for reusable workflow
|
||||
jobs. The independently reviewed, fully green #2755 fixture correction merged
|
||||
as fb28ec153 and restored real x64 dyld CLI admission without altering the
|
||||
original same-named arm64e assertions.
|
||||
No production source is duplicated by the follow-up.
|
||||
|
|
@ -178,6 +178,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",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export const SCOPES = {
|
|||
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
|
||||
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
|
||||
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
||||
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
|
||||
"tests/boundary/process/ProcessEvidence.host.test.ts",
|
||||
"tests/acceptance/webModuleCli.test.ts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export function assertFeatureTrace(
|
||||
trace: unknown,
|
||||
procedure: { readonly address: string },
|
||||
): void;
|
||||
|
||||
export function findMappedNonFunction(
|
||||
strings: readonly { readonly address: string }[],
|
||||
resolveAt: (address: string) => Promise<unknown>,
|
||||
): Promise<string | undefined>;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]);
|
||||
|
||||
/**
|
||||
* Assert the `trace_feature` result of the real IDA lane for one selected
|
||||
* procedure. The query is a substring search, so the trace may also match other
|
||||
* procedures and strings; each reference is checked against its own matched
|
||||
* target, and a source outside every function (a data reference) is valid.
|
||||
*/
|
||||
export function assertFeatureTrace(trace, procedure) {
|
||||
assert.ok(
|
||||
trace.matches.some(
|
||||
({ type, address }) =>
|
||||
type === "procedure" && address === procedure.address,
|
||||
),
|
||||
"The trace omitted the selected procedure's match.",
|
||||
);
|
||||
assert.equal(typeof trace.truncated, "boolean");
|
||||
const matched = new Set(trace.matches.map(({ address }) => address));
|
||||
for (const reference of trace.references) {
|
||||
assert.ok(
|
||||
matched.has(reference.target_address),
|
||||
`A reference targets ${reference.target_address}, which is not a match.`,
|
||||
);
|
||||
const { containing_procedure: containing } = reference;
|
||||
if (containing.found)
|
||||
assert.equal(typeof containing.procedure.address, "string");
|
||||
else assert.ok(NEGATIVE_REASONS.has(containing.reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a string that IDA maps outside every function. A string inside a
|
||||
* function chunk (inline data in code) is valid and skipped, so the strings are
|
||||
* tried in order. Any other answer must be "not in a procedure": IDA lists only
|
||||
* mapped strings, so an unmapped answer is a containment defect and fails the
|
||||
* lane. Returns the address, or undefined when every string is inside a function.
|
||||
*/
|
||||
export async function findMappedNonFunction(strings, resolveAt) {
|
||||
for (const { address } of strings) {
|
||||
const resolved = await resolveAt(address);
|
||||
if (resolved.found) continue;
|
||||
assert.equal(
|
||||
resolved.reason,
|
||||
"not_in_procedure",
|
||||
`The listed string at ${address} resolved as ${resolved.reason}.`,
|
||||
);
|
||||
return address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
|
|
@ -18,10 +18,6 @@ import {
|
|||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||
|
|
@ -32,6 +28,13 @@ assert(
|
|||
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||
);
|
||||
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
|
||||
// before the verifier switches to its disposable workspace.
|
||||
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
|
||||
const launch = (args) =>
|
||||
entryPoint === undefined
|
||||
? [command, args]
|
||||
: [process.execPath, [entryPoint, ...args]];
|
||||
const verifier = createVerifierRun();
|
||||
const profile = join(lab, "selected profile");
|
||||
const account = join(lab, "account");
|
||||
|
|
@ -54,14 +57,18 @@ const baseEnvironment = {
|
|||
let clientVersion;
|
||||
try {
|
||||
clientVersion = (
|
||||
await exec(command, ["--version"], {
|
||||
await exec(...launch(["--version"]), {
|
||||
env: baseEnvironment,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const original = JSON.stringify(
|
||||
|
|
@ -93,8 +100,8 @@ for (const step of [
|
|||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + "/") ||
|
||||
a.target.startsWith(sharedSkills + "/"),
|
||||
a.target.startsWith(profile + sep) ||
|
||||
a.target.startsWith(sharedSkills + sep),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
|
|
@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({
|
|||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||
const messageText = (body.messages ?? [])
|
||||
.map((message) =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
selectedSkillLoaded ||= messageText.includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const toolText = JSON.stringify(
|
||||
|
|
@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({
|
|||
const invoke = tools.find((t) => t.name === "tool_call");
|
||||
const skill = tools.find((t) => t.name === "skill");
|
||||
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||
const persisted = toolText.match(
|
||||
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||
);
|
||||
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
|
|
@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({
|
|||
artifactPath = persisted[1];
|
||||
assert(
|
||||
(await realpath(artifactPath)).startsWith(
|
||||
(await realpath(runtime)) + "/",
|
||||
(await realpath(runtime)) + sep,
|
||||
),
|
||||
);
|
||||
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
|
||||
const encodedScript = Buffer.from(script).toString("base64");
|
||||
message = call(shell.name, {
|
||||
command: `node -e ${quote(script)}`,
|
||||
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
|
||||
description:
|
||||
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||
});
|
||||
|
|
@ -329,7 +342,7 @@ const args = [
|
|||
let response;
|
||||
let failure;
|
||||
try {
|
||||
response = await exec(command, args, {
|
||||
response = await exec(...launch(args), {
|
||||
env,
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,479 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const ca65 = process.env.REA_CA65 ?? "ca65";
|
||||
const ld65 = process.env.REA_LD65 ?? "ld65";
|
||||
const toolchain = {};
|
||||
for (const [name, command] of Object.entries({ ca65, ld65 })) {
|
||||
const version = await exec(command, ["--version"], { timeout: 10000 });
|
||||
toolchain[name] = (version.stdout + version.stderr).trim();
|
||||
}
|
||||
await mkdir(join(root, "build"), { recursive: true });
|
||||
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "image.bin");
|
||||
const object = join(workspace, "image.o");
|
||||
const entrypoint = join(root, "scripts", "rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
TMP: runtime,
|
||||
TEMP: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await exec(
|
||||
ca65,
|
||||
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await exec(
|
||||
ld65,
|
||||
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const bytes = await readFile(path);
|
||||
// Independently known opcodes and link addresses from the source fixture.
|
||||
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await client.connect(transport);
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x008001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(opened.architecture, "6502");
|
||||
assert.equal(opened.entry_point, "0x8001");
|
||||
const status = await call("binary_session");
|
||||
const image = evidence(await call("inspect_native_load_image"), sha256);
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image.normalized_result),
|
||||
);
|
||||
assert.equal(image.normalized_result.architecture, "6502");
|
||||
assert.equal(image.normalized_result.base_address, "0x8001");
|
||||
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
|
||||
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
||||
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
||||
const memory = evidence(
|
||||
await call("read_bytes", { address: "0x8001", length: bytes.length }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = evidence(await call("list_procedures"), sha256);
|
||||
for (const address of ["0x8001", "0x8010"])
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
(procedure) => procedure.address === address,
|
||||
),
|
||||
);
|
||||
const instruction = evidence(
|
||||
await call("inspect_native_instruction", { address: "0x8003" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
instruction.normalized_result.references.some(
|
||||
(reference) => reference.call && reference.target_address === "0x8010",
|
||||
),
|
||||
);
|
||||
const pseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x8010" }),
|
||||
sha256,
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
|
||||
const filePseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
||||
assert.ok(
|
||||
filePseudo.locations.some(
|
||||
(location) => location.kind === "file-offset" && location.offset === 15,
|
||||
),
|
||||
);
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0xffff",
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
|
||||
const preserved = await call("binary_session");
|
||||
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
||||
assert.equal(preserved.entry_point, "0x8001");
|
||||
await call("close_binary");
|
||||
await client.ping();
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = parseEvidence(JSON.parse(stdout));
|
||||
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
||||
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
||||
assert.equal(cli.subject.digest.sha256, sha256);
|
||||
const fileCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
path,
|
||||
"--file-offset=0xf",
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
|
||||
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
||||
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
||||
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
const lowMemory = [];
|
||||
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
|
||||
// that overlapping zero-page/stack definitions never overlay the selected image.
|
||||
for (const [entry, fullAddressSpace] of [
|
||||
[0xff, false],
|
||||
[0, true],
|
||||
]) {
|
||||
const lowPath = join(workspace, `low-${entry}.bin`);
|
||||
await exec(
|
||||
ld65,
|
||||
[
|
||||
"-C",
|
||||
join(root, "tests/conformance/ghidra/6502.cfg"),
|
||||
"--start-addr",
|
||||
String(entry),
|
||||
"-o",
|
||||
lowPath,
|
||||
object,
|
||||
],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const linked = await readFile(lowPath);
|
||||
const expectedCode = Buffer.from(
|
||||
"a907200000c90ad003a92a60a9006018690360",
|
||||
"hex",
|
||||
);
|
||||
expectedCode.writeUInt16LE(entry + 15, 3);
|
||||
assert.deepEqual(linked, expectedCode);
|
||||
const lowBytes = fullAddressSpace
|
||||
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
|
||||
: linked;
|
||||
await writeFile(lowPath, lowBytes);
|
||||
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
|
||||
const lowEntry = `0x${entry.toString(16)}`;
|
||||
const lowOpened = await call("open_binary", {
|
||||
path: lowPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: lowEntry,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(lowOpened.entry_point, lowEntry);
|
||||
const lowImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(
|
||||
lowImage.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(lowImage.normalized_result),
|
||||
);
|
||||
assert.equal(lowImage.normalized_result.base_address, lowEntry);
|
||||
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
|
||||
const fileMappings =
|
||||
lowImage.normalized_result.observations.mappings.filter(
|
||||
(mapping) => mapping.source_file_index !== null,
|
||||
);
|
||||
assert.equal(fileMappings.length, 1);
|
||||
assert.equal(
|
||||
fileMappings[0].address_space,
|
||||
lowImage.normalized_result.observations.default_address_space,
|
||||
);
|
||||
assert.equal(fileMappings[0].overlay, false);
|
||||
assert.equal(fileMappings[0].start, lowEntry);
|
||||
assert.equal(fileMappings[0].length, lowBytes.length);
|
||||
const lowRead = evidence(
|
||||
await call("read_bytes", { address: lowEntry, length: linked.length }),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
|
||||
const lowCall = evidence(
|
||||
await call("inspect_native_instruction", {
|
||||
address: `0x${(entry + 2).toString(16)}`,
|
||||
}),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
lowCall.normalized_result.references.some(
|
||||
(reference) =>
|
||||
reference.call &&
|
||||
reference.target_address === `0x${(entry + 15).toString(16)}`,
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
const lowCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
lowPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
lowEntry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
|
||||
assert.deepEqual(
|
||||
lowCliEvidence.normalized_result,
|
||||
lowImage.normalized_result,
|
||||
);
|
||||
assert.equal(
|
||||
lowCliEvidence.analysis_profile.digest,
|
||||
lowImage.analysis_profile.digest,
|
||||
);
|
||||
assert.deepEqual(await readFile(lowPath), lowBytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
lowMemory.push({
|
||||
entry: lowEntry,
|
||||
bytes: lowBytes.length,
|
||||
sha256: lowSha256,
|
||||
});
|
||||
}
|
||||
let windowsReparseAdmission = "not-applicable";
|
||||
if (process.platform === "win32") {
|
||||
const junction = join(workspace, "linked");
|
||||
await symlink(workspace, junction, "junction");
|
||||
try {
|
||||
const linkedPath = join(junction, "image.bin");
|
||||
// Reopening the same bytes through a junction must replace the direct session.
|
||||
await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
const directImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(directImage.normalized_result.status, "verified");
|
||||
// Canonical file identity must not bypass admission of the new path.
|
||||
await call("open_binary", {
|
||||
path: linkedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
// open_binary selects a lazy session; the first analysis admits the snapshot.
|
||||
const linkedImage = await client.callTool(
|
||||
{ name: "inspect_native_load_image", arguments: {} },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.equal(linkedImage.isError, true);
|
||||
assert.match(
|
||||
JSON.stringify(linkedImage),
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
await call("close_binary");
|
||||
const snapshotPath = join(workspace, "direct-snapshot.json");
|
||||
const cliArgs = (selectedPath) => [
|
||||
entrypoint,
|
||||
"analyze",
|
||||
selectedPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--snapshot",
|
||||
snapshotPath,
|
||||
"--json",
|
||||
];
|
||||
const saved = await exec(process.execPath, cliArgs(path), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(
|
||||
evidence(JSON.parse(saved.stdout), sha256).operation,
|
||||
"binary_overview",
|
||||
);
|
||||
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
assert.ok(
|
||||
savedSnapshot.workflow_entries.some(
|
||||
({ operation }) => operation === "binary_overview",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
// Replaying a matching snapshot must still reach native path admission.
|
||||
await assert.rejects(
|
||||
exec(process.execPath, cliArgs(linkedPath), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
(cause) => {
|
||||
assert.match(
|
||||
cause.stdout + cause.stderr,
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
|
||||
} finally {
|
||||
await unlink(junction);
|
||||
}
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-6502",
|
||||
host: process.platform,
|
||||
toolchain,
|
||||
provider: image.provider,
|
||||
target: {
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
architecture: "6502",
|
||||
entry: "0x8001",
|
||||
},
|
||||
cli_mcp_load_image_parity: true,
|
||||
instructions_and_call_reference: true,
|
||||
decompilation: true,
|
||||
file_offset_decompilation_cli_mcp: true,
|
||||
invalid_mapping_preserves_session: true,
|
||||
low_memory_cli_mcp_parity: lowMemory,
|
||||
windows_reparse_admission: windowsReparseAdmission,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
}
|
||||
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
||||
return requireMcpOperationResult(result, name);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
function evidence(value, sha256) {
|
||||
const result = parseEvidence(value);
|
||||
assert.equal(result.subject.digest.sha256, sha256);
|
||||
assert.equal(result.subject.architecture, "6502");
|
||||
assert.equal(result.subject.format, "raw-binary");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -45,8 +45,13 @@ const transport = new StdioClientTransport({
|
|||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
|
||||
);
|
||||
const literalSource = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
|
||||
);
|
||||
const binary = join(root, "swift-inventory");
|
||||
const literalBinary = join(root, "swift-literals");
|
||||
const reports = [];
|
||||
let literalReport;
|
||||
try {
|
||||
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
|
||||
timeout: 60_000,
|
||||
|
|
@ -54,6 +59,19 @@ try {
|
|||
await mkdir(join(root, "copied"));
|
||||
const copied = join(root, "copied", "swift-inventory");
|
||||
await copyFile(binary, copied);
|
||||
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
|
||||
await execute(
|
||||
"swiftc",
|
||||
[
|
||||
"-O",
|
||||
"-target",
|
||||
"arm64-apple-macosx13.0",
|
||||
literalSource,
|
||||
"-o",
|
||||
literalBinary,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const [variant, path] of [
|
||||
["debug", binary],
|
||||
|
|
@ -132,6 +150,7 @@ try {
|
|||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
literalReport = await verifyStringLiterals();
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
|
|
@ -143,6 +162,7 @@ console.log(
|
|||
ok: true,
|
||||
provider: "ghidra",
|
||||
fixtures: reports,
|
||||
swift_string_literals: literalReport,
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
},
|
||||
null,
|
||||
|
|
@ -159,3 +179,51 @@ async function call(name, arguments_) {
|
|||
toolContract(name).outputSchema.parse(response.structuredContent);
|
||||
return requireMcpOperationResult(response, name);
|
||||
}
|
||||
|
||||
// Literal text lives only in register constants, so it must come from the decoded pairs.
|
||||
async function verifyStringLiterals() {
|
||||
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
|
||||
try {
|
||||
const procedures = await call("list_procedures", {});
|
||||
const expected = {
|
||||
smallLiteral: [["SleepDisabled", "small"]],
|
||||
largeLiteral: [["The power setting could not be changed.", "large"]],
|
||||
unicodeLiterals: [
|
||||
["café", "small"],
|
||||
["Résumé of the naïve décor, written twice", "large"],
|
||||
],
|
||||
emptyAndControlLiterals: [
|
||||
["", "small"],
|
||||
["tab\there", "small"],
|
||||
],
|
||||
returnedLiteral: [["Holding awake", "small"]],
|
||||
};
|
||||
const decoded = {};
|
||||
for (const [name, literals] of Object.entries(expected)) {
|
||||
const procedure = procedures.find(({ value }) => value.includes(name));
|
||||
assert(procedure, `${name} is missing from the procedure inventory`);
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: procedure.address,
|
||||
});
|
||||
const observed = dossier.swift_string_literals;
|
||||
assert(Array.isArray(observed), `${name} recorded no Swift literals`);
|
||||
assert.deepEqual(dossier.swift_string_literal_omissions, []);
|
||||
assert.deepEqual(
|
||||
observed.map(({ value, representation }) => [value, representation]),
|
||||
literals,
|
||||
);
|
||||
for (const literal of observed) {
|
||||
assert.deepEqual(literal.registers, ["x0", "x1"]);
|
||||
assert.equal(
|
||||
literal.address === null,
|
||||
literal.representation === "small",
|
||||
`${literal.value} storage does not match its representation`,
|
||||
);
|
||||
}
|
||||
decoded[name] = observed.length;
|
||||
}
|
||||
return decoded;
|
||||
} finally {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
import {
|
||||
assertFeatureTrace,
|
||||
findMappedNonFunction,
|
||||
} from "./lib/ida-feature-trace.mjs";
|
||||
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
|
|
@ -6,7 +10,7 @@ import { execFile } from "node:child_process";
|
|||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
|
|
@ -261,6 +265,61 @@ const mcp = async (cliDossier) => {
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (config.mode === "headless") {
|
||||
const documents = observe(await call("list_documents", {}));
|
||||
assert.equal(documents.length, 1);
|
||||
assert.equal(basename(documents[0]), basename(target));
|
||||
assert.ok(
|
||||
!workspaces.has(dirname(documents[0])),
|
||||
"list_documents reported the private workspace copy.",
|
||||
);
|
||||
const resolveAt = async (address) =>
|
||||
observe(await call("resolve_containing_procedure", { address }));
|
||||
const instructionAddresses = dossier.assembly
|
||||
.map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1])
|
||||
.filter((address) => address !== undefined);
|
||||
const queries = [
|
||||
dossier.procedure.address,
|
||||
...instructionAddresses.slice(1, 2),
|
||||
];
|
||||
for (const queried of queries) {
|
||||
const containing = await resolveAt(queried);
|
||||
assert.equal(containing.found, true);
|
||||
assert.equal(containing.procedure.address, dossier.procedure.address);
|
||||
assert.equal(containing.procedure.name, dossier.procedure.name);
|
||||
}
|
||||
const unmapped = await resolveAt("0xffffffffffffff00");
|
||||
assert.equal(unmapped.found, false);
|
||||
assert.equal(unmapped.reason, "outside_segments");
|
||||
const strings = observe(await call("list_strings", {}));
|
||||
const mappedString = await findMappedNonFunction(strings, resolveAt);
|
||||
if (mappedString === undefined)
|
||||
report.unverified.push(
|
||||
"Mapped non-function containment: no string of the target is mapped outside every function.",
|
||||
);
|
||||
const context = await client.callTool({
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: dossier.procedure.address },
|
||||
});
|
||||
assert.notEqual(context.isError, true);
|
||||
const contextResult = context.structuredContent.result;
|
||||
assert.equal(contextResult.document, documents[0]);
|
||||
assert.equal(contextResult.procedure.state, "available");
|
||||
assert.equal(
|
||||
contextResult.procedure.value.procedure.address,
|
||||
dossier.procedure.address,
|
||||
);
|
||||
const traced = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: dossier.procedure.name },
|
||||
});
|
||||
assert.notEqual(traced.isError, true);
|
||||
const trace = traced.structuredContent.normalized_result;
|
||||
assertFeatureTrace(trace, dossier.procedure);
|
||||
report.checks.push(
|
||||
"headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing",
|
||||
);
|
||||
}
|
||||
if (values["expected-string"] === undefined)
|
||||
report.unverified.push(
|
||||
"String inventory/search correctness: no independent expected literal supplied.",
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ if (
|
|||
);
|
||||
const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length;
|
||||
if (
|
||||
bytes.toString("ascii", 0, 2) !== "MZ" ||
|
||||
bytes.toString("latin1", 0, 2) !== "MZ" ||
|
||||
offset > bytes.length - 26 ||
|
||||
bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.readUInt16LE(offset + 4) !== 0x8664 ||
|
||||
bytes.readUInt16LE(offset + 24) !== 0x20b
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,8 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import * as nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
|
|
@ -18,247 +11,348 @@ import { firmwareResultSchemas } from "../../../dist/domain/firmware/firmwareAna
|
|||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashFirmwareFile } from "../../../dist/firmware/FirmwareFiles.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const entrypoint = resolve(
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { join, resolve, dirname } = nodePath;
|
||||
|
||||
const CALL_TIMEOUT_MS = 150_000;
|
||||
const CONNECT_TIMEOUT_MS = 30_000;
|
||||
const SEVENZIP_TIMEOUT_MS = 10_000;
|
||||
const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENTRYPOINT = resolve(
|
||||
process.env.REA_FIRMWARE_TEST_ENTRYPOINT ??
|
||||
join(repository, "scripts/rea.mjs"),
|
||||
join(REPO_ROOT, "scripts/rea.mjs"),
|
||||
);
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const path = process.env[variable];
|
||||
if (path === undefined)
|
||||
throw new Error(
|
||||
`verify:firmware requires ${variable}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
await access(path);
|
||||
}
|
||||
try {
|
||||
await exec("7z", ["i"], { timeout: 10_000 });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const fixtureRoot = resolve(
|
||||
const FIXTURE_ROOT = resolve(
|
||||
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
|
||||
join(repository, "_reference/firmware-integration/generated"),
|
||||
join(REPO_ROOT, "_reference/firmware-integration/generated"),
|
||||
);
|
||||
await access(join(fixtureRoot, "oracle.json")).catch((cause) => {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisite checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkPrerequisites() {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`verify:firmware requires ${name}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
}
|
||||
await fs.access(value);
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("7z", ["i"], { timeout: SEVENZIP_TIMEOUT_MS });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(join(FIXTURE_ROOT, "oracle.json"));
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await checkPrerequisites();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const oracle = JSON.parse(
|
||||
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
|
||||
await fs.readFile(join(FIXTURE_ROOT, "oracle.json"), "utf8"),
|
||||
);
|
||||
const path = join(fixtureRoot, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(path), oracle.sha256);
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => value !== undefined),
|
||||
const firmwarePath = join(FIXTURE_ROOT, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(firmwarePath), oracle.sha256);
|
||||
|
||||
const workDir = await fs.mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const baseEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined),
|
||||
);
|
||||
const client = new Client({ name: "rea-real-firmware-verifier", version: "1" });
|
||||
const cli = async (args, env = environment) => {
|
||||
const response = await exec(
|
||||
const mcpClient = new Client({
|
||||
name: "rea-real-firmware-verifier",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invocation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runCli(args, env = baseEnv) {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--format", "json"],
|
||||
[ENTRYPOINT, ...args, "--format", "json"],
|
||||
{
|
||||
env,
|
||||
cwd: repository,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
cwd: REPO_ROOT,
|
||||
timeout: CALL_TIMEOUT_MS,
|
||||
maxBuffer: MAX_STDOUT_BYTES,
|
||||
},
|
||||
);
|
||||
return parseEvidence(JSON.parse(response.stdout));
|
||||
};
|
||||
const call = async (name, args) => {
|
||||
const response = await client.callTool(
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const result = await mcpClient.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 150_000 },
|
||||
{ timeout: CALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(response.structuredContent !== undefined);
|
||||
return response.structuredContent;
|
||||
};
|
||||
const verifyRegions = (evidence) => {
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(result.structuredContent !== undefined);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result verifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function verifyRegions(evidence) {
|
||||
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
|
||||
const result = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
|
||||
const parsed = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const gzip = result.regions.find(
|
||||
(region) => region.signature === "gzip" && region.offset === oracle.offset,
|
||||
|
||||
const gzipRegion = parsed.regions.find(
|
||||
(r) => r.signature === "gzip" && r.offset === oracle.offset,
|
||||
);
|
||||
assert.ok(
|
||||
gzip !== undefined,
|
||||
gzipRegion !== undefined,
|
||||
"Binwalk must find the independently generated gzip offset",
|
||||
);
|
||||
assert.equal(gzip.reported_size, oracle.length);
|
||||
assert.equal(gzip.size_basis, "provider_reported_validation_unknown");
|
||||
return result.regions.map(
|
||||
({ provider_id: _provider_id, ...region }) => region,
|
||||
);
|
||||
};
|
||||
const verifyFiles = async (evidence) => {
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
assert.equal(gzipRegion.reported_size, oracle.length);
|
||||
assert.equal(gzipRegion.size_basis, "provider_reported_validation_unknown");
|
||||
|
||||
// Strip provider-specific ids so CLI and MCP results compare equal.
|
||||
return parsed.regions.map((region) => {
|
||||
const { provider_id: _ignored, ...rest } = region;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyFiles(evidence) {
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip"));
|
||||
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "gzip"));
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const file = result.files.find((candidate) =>
|
||||
candidate.relative_path.endsWith(`/${suffix}`),
|
||||
const entry = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(file !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(file.sha256, expected.sha256);
|
||||
assert.equal(file.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(file.path), expected.sha256);
|
||||
assert.equal(file.original_file_range, null);
|
||||
assert.equal(file.runtime_address, null);
|
||||
assert.ok(entry !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(entry.sha256, expected.sha256);
|
||||
assert.equal(entry.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(entry.path), expected.sha256);
|
||||
assert.equal(entry.original_file_range, null);
|
||||
assert.equal(entry.runtime_address, null);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
const inspected = await cli(["inspect-firmware-regions", path]);
|
||||
const cliRegions = verifyRegions(inspected);
|
||||
const extracted = await cli([
|
||||
"extract-firmware",
|
||||
path,
|
||||
join(root, "cli-output"),
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const toContentIdentity = (parsed) =>
|
||||
parsed.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verification stages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stageCli() {
|
||||
const regionsEvidence = await runCli([
|
||||
"inspect-firmware-regions",
|
||||
firmwarePath,
|
||||
]);
|
||||
const cliFiles = await verifyFiles(extracted);
|
||||
assert.equal(cliFiles.coverage, "partial");
|
||||
const regions = verifyRegions(regionsEvidence);
|
||||
|
||||
const extractEvidence = await runCli([
|
||||
"extract-firmware",
|
||||
firmwarePath,
|
||||
join(workDir, "cli-output"),
|
||||
]);
|
||||
const files = await verifyFiles(extractEvidence);
|
||||
|
||||
assert.equal(files.coverage, "partial");
|
||||
assert.ok(
|
||||
cliFiles.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.handler === null &&
|
||||
chunk.range.offset === 0 &&
|
||||
chunk.range.length === 64,
|
||||
files.chunks.some(
|
||||
(c) =>
|
||||
c.handler === null && c.range.offset === 0 && c.range.length === 64,
|
||||
),
|
||||
);
|
||||
await client.connect(
|
||||
new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const inspectedMcp = await call("inspect_firmware_regions", { path });
|
||||
const mcpEvidence = parseEvidence(inspectedMcp);
|
||||
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
|
||||
const extractedMcp = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "mcp-output"),
|
||||
|
||||
return { regions, files };
|
||||
}
|
||||
|
||||
async function connectMcp() {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [ENTRYPOINT, "mcp"],
|
||||
cwd: REPO_ROOT,
|
||||
env: baseEnv,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
|
||||
const contentIdentity = (result) =>
|
||||
result.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles));
|
||||
const selected = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "selected-output"),
|
||||
await mcpClient.connect(transport, { timeout: CONNECT_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function stageMcp(cli) {
|
||||
const regionsRaw = await callTool("inspect_firmware_regions", {
|
||||
path: firmwarePath,
|
||||
});
|
||||
assert.deepEqual(verifyRegions(parseEvidence(regionsRaw)), cli.regions);
|
||||
|
||||
const extractRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "mcp-output"),
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractRaw));
|
||||
assert.deepEqual(toContentIdentity(mcpFiles), toContentIdentity(cli.files));
|
||||
|
||||
const rangedRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "selected-output"),
|
||||
range: { offset: oracle.offset, length: oracle.length },
|
||||
});
|
||||
const selectedFiles = await verifyFiles(parseEvidence(selected));
|
||||
assert.equal(selectedFiles.selection.offset, oracle.offset);
|
||||
assert.equal(selectedFiles.selection.length, oracle.length);
|
||||
const depth = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "depth-output"),
|
||||
const ranged = await verifyFiles(parseEvidence(rangedRaw));
|
||||
assert.equal(ranged.selection.offset, oracle.offset);
|
||||
assert.equal(ranged.selection.length, oracle.length);
|
||||
|
||||
const depthRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "depth-output"),
|
||||
max_depth: 1,
|
||||
});
|
||||
const depthResult = firmwareResultSchemas.extract_firmware.parse(
|
||||
depth.normalized_result,
|
||||
const depthParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
depthRaw.normalized_result,
|
||||
);
|
||||
assert.equal(depthResult.coverage, "partial");
|
||||
assert.ok(depthResult.depth_limited_paths.length > 0);
|
||||
assert.equal(depthParsed.coverage, "partial");
|
||||
assert.ok(depthParsed.depth_limited_paths.length > 0);
|
||||
|
||||
console.log(
|
||||
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
|
||||
);
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") {
|
||||
const ext4 = join(fixtureRoot, "ext4.bin");
|
||||
await access(ext4);
|
||||
const response = await call("extract_firmware", {
|
||||
path: ext4,
|
||||
output_directory: join(root, "ext4-output"),
|
||||
});
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
response.normalized_result,
|
||||
|
||||
return mcpFiles;
|
||||
}
|
||||
|
||||
async function stageExt4() {
|
||||
const ext4Path = join(FIXTURE_ROOT, "ext4.bin");
|
||||
await fs.access(ext4Path);
|
||||
|
||||
const raw = await callTool("extract_firmware", {
|
||||
path: ext4Path,
|
||||
output_directory: join(workDir, "ext4-output"),
|
||||
});
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
raw.normalized_result,
|
||||
);
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "extfs"));
|
||||
assert.equal(parsed.coverage, "complete");
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const match = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
|
||||
assert.equal(result.coverage, "complete");
|
||||
for (const [suffix, expected] of Object.entries(oracle.files))
|
||||
assert.equal(
|
||||
result.files.find((file) => file.relative_path.endsWith(`/${suffix}`))
|
||||
?.sha256,
|
||||
expected.sha256,
|
||||
);
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
// Preserve the process-ownership utility while withholding all extractors.
|
||||
const withoutExtractors = join(root, "without-extractors");
|
||||
await access("/bin/ps");
|
||||
await mkdir(withoutExtractors);
|
||||
await symlink("/bin/ps", join(withoutExtractors, "ps"));
|
||||
const missing = await cli(
|
||||
["extract-firmware", ext4, join(root, "missing-extractor-output")],
|
||||
{ ...environment, PATH: withoutExtractors },
|
||||
);
|
||||
const partial = firmwareResultSchemas.extract_firmware.parse(
|
||||
missing.normalized_result,
|
||||
);
|
||||
assert.equal(partial.coverage, "partial");
|
||||
assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
}
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
const child = mcpFiles.files.find((file) =>
|
||||
file.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(child !== undefined);
|
||||
await call("open_binary", { path: child.path, provider_id: "ghidra" });
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const evidence = parseEvidence(dossier);
|
||||
assert.equal(
|
||||
evidence.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
const pseudocode = await call("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
await call("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
assert.equal(match?.sha256, expected.sha256);
|
||||
}
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
|
||||
// Keep the process-ownership utility available but hide every extractor.
|
||||
const emptyBin = join(workDir, "without-extractors");
|
||||
await fs.access("/bin/ps");
|
||||
await fs.mkdir(emptyBin);
|
||||
await fs.symlink("/bin/ps", join(emptyBin, "ps"));
|
||||
|
||||
const degraded = await runCli(
|
||||
["extract-firmware", ext4Path, join(workDir, "missing-extractor-output")],
|
||||
{ ...baseEnv, PATH: emptyBin },
|
||||
);
|
||||
const degradedParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
degraded.normalized_result,
|
||||
);
|
||||
assert.equal(degradedParsed.coverage, "partial");
|
||||
assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
}
|
||||
|
||||
async function stageGhidra(mcpFiles) {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined) {
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
}
|
||||
|
||||
const probe = mcpFiles.files.find((f) =>
|
||||
f.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(probe !== undefined);
|
||||
|
||||
await callTool("open_binary", { path: probe.path, provider_id: "ghidra" });
|
||||
|
||||
const dossierRaw = await callTool("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const dossier = parseEvidence(dossierRaw);
|
||||
assert.equal(
|
||||
await hashFirmwareFile(path),
|
||||
dossier.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
|
||||
const pseudoRaw = await callTool("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudoRaw).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(dossier.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
|
||||
await callTool("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
try {
|
||||
const cli = await stageCli();
|
||||
await connectMcp();
|
||||
const mcpFiles = await stageMcp(cli);
|
||||
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") await stageExt4();
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1")
|
||||
await stageGhidra(mcpFiles);
|
||||
|
||||
assert.equal(
|
||||
await hashFirmwareFile(firmwarePath),
|
||||
oracle.sha256,
|
||||
"Original firmware bytes must remain unchanged",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mcpClient.close();
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -810,6 +810,36 @@ exit 1`,
|
|||
expect(result.error.message).toContain("overwrite");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["offline", false, "error: device offline"],
|
||||
["unauthorized", false, "error: device unauthorized"],
|
||||
["offline", true, "error: device offline"],
|
||||
] as const)(
|
||||
"reports the %s device state when the push existence probe cannot run (overwrite %s)",
|
||||
async (state, overwrite, stderr) => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "x");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
overwrite,
|
||||
},
|
||||
});
|
||||
// Existence is unknown, so nothing is claimed about the path and no push runs.
|
||||
expect(projected).toMatchObject({ code: "unsupported_target" });
|
||||
expect(projected.message).toContain(state);
|
||||
expect(projected.message).not.toContain("already exists");
|
||||
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a local push source that does not exist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
|
|
|||
|
|
@ -1623,17 +1623,15 @@ export class AdbProvider {
|
|||
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
|
||||
signal,
|
||||
);
|
||||
let deviceExists: boolean;
|
||||
if (existsProbe.ok) deviceExists = true;
|
||||
else if (existsProbe.error instanceof AnalysisProtocolError)
|
||||
deviceExists = false;
|
||||
else if (existsProbe.error instanceof AnalysisTimeoutError)
|
||||
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
|
||||
// Any other probe failure leaves existence unknown, so it is reported as
|
||||
// is rather than read as an existing path or followed by a push.
|
||||
if (
|
||||
!existsProbe.ok &&
|
||||
!(existsProbe.error instanceof AnalysisProtocolError)
|
||||
)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisCancelledError)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisResourceConstraintError)
|
||||
return existsProbe;
|
||||
else deviceExists = true; // authorization/refusal states surface on push instead
|
||||
const deviceExists = existsProbe.ok;
|
||||
if (deviceExists && !input.overwrite)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ export interface AnalysisExecution {
|
|||
readonly limitations: readonly string[];
|
||||
/** Provider-owned completeness assessment for caller/callee observations. */
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
/**
|
||||
* Whether the provider capped an `xrefs` read: true when it reported a
|
||||
* truncation, false when it explicitly reported none. Absent when it reports
|
||||
* nothing, which is not a completeness claim. Never part of Evidence or tool
|
||||
* output.
|
||||
*/
|
||||
readonly referencesTruncated?: boolean;
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
readonly subject: EvidenceSubjectTarget | null;
|
||||
}
|
||||
|
|
@ -59,6 +66,7 @@ interface CreateAnalysisExecutionOptions {
|
|||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly limitations?: readonly string[];
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
readonly referencesTruncated?: boolean;
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly subject?: EvidenceSubjectTarget;
|
||||
}
|
||||
|
|
@ -103,6 +111,9 @@ export function createAnalysisExecution(
|
|||
options.relationshipCoverage,
|
||||
),
|
||||
}),
|
||||
...(options.referencesTruncated === undefined
|
||||
? {}
|
||||
: { referencesTruncated: options.referencesTruncated }),
|
||||
locations: [...(options.locations ?? [])],
|
||||
subject: options.subject ?? null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -497,3 +497,71 @@ it("reports host denial before target mismatch but keeps composed workflow prere
|
|||
).reason,
|
||||
).toBe("target_required");
|
||||
});
|
||||
|
||||
describe("trace_feature requirements", () => {
|
||||
const capabilities = (operations: readonly string[]) =>
|
||||
operations.map((operation) => ({
|
||||
operation,
|
||||
available: true,
|
||||
reason: null,
|
||||
}));
|
||||
|
||||
it("needs the search operations the workflow actually calls", () => {
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities([
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
});
|
||||
|
||||
it.each(["search_strings", "search_procedures"])(
|
||||
"is unavailable without %s",
|
||||
(missing) => {
|
||||
const operations = [
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
].filter((operation) => operation !== missing);
|
||||
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities(operations),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
},
|
||||
);
|
||||
|
||||
it("is unavailable when only the list operations exist", () => {
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities([
|
||||
"list_strings",
|
||||
"list_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
|
|||
analyze_function: ["analyze_function"],
|
||||
inspect_native_api: ["analyze_function"],
|
||||
trace_feature: [
|
||||
"list_strings",
|
||||
"list_procedures",
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
],
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import {
|
||||
parse as parseYaml,
|
||||
parseDocument as parseYamlDocument,
|
||||
stringify as stringifyYaml,
|
||||
} from "yaml";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
|
|
@ -12,6 +8,10 @@ import {
|
|||
editDshDocument,
|
||||
dshClientRegistrationEntry,
|
||||
} from "./DshCordisPatch.js";
|
||||
import {
|
||||
editHermesConfigurationYaml,
|
||||
parseHermesConfigurationYaml,
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
import {
|
||||
applyEdits,
|
||||
findNodeAtLocation,
|
||||
|
|
@ -118,7 +118,10 @@ const parseDocument = (
|
|||
if (isEmptyClientConfigurationText(text)) return {};
|
||||
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
|
||||
if (format === "hermes")
|
||||
return requireObject(parseYaml(text), "The configuration root");
|
||||
return requireObject(
|
||||
parseHermesConfigurationYaml(text),
|
||||
"The configuration root",
|
||||
);
|
||||
// ZCode loads nonempty native files with strict JSON.parse.
|
||||
if (format === "zcode")
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
|
|
@ -1419,12 +1422,11 @@ const serializeHermesConfiguration = (
|
|||
typeof servers === "object" && servers !== null
|
||||
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
|
||||
: undefined;
|
||||
const yamlDocument = parseYamlDocument(originalText);
|
||||
if (entry === undefined)
|
||||
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
|
||||
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return yamlDocument.toString({ lineWidth: 0 });
|
||||
return editHermesConfigurationYaml(
|
||||
originalText,
|
||||
[serversKey, PRODUCT_IDENTITY.mcpServerKey],
|
||||
entry,
|
||||
);
|
||||
};
|
||||
|
||||
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
|
@ -16,6 +19,12 @@ type AnalysisCall = (
|
|||
signal?: AbortSignal,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>;
|
||||
|
||||
type ObserveCall = (
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
|
||||
type LiteralSearchSource = readonly [
|
||||
"search_strings" | "search_procedures",
|
||||
"string" | "procedure",
|
||||
|
|
@ -39,6 +48,7 @@ export interface LiteralTraceInput {
|
|||
/** Trace matching strings and procedures through provider xrefs. */
|
||||
export const traceLiteralFeature = async (
|
||||
call: AnalysisCall,
|
||||
observe: ObserveCall,
|
||||
input: LiteralTraceInput,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
|
|
@ -49,7 +59,7 @@ export const traceLiteralFeature = async (
|
|||
signal,
|
||||
);
|
||||
if (!searched.ok) return searched;
|
||||
const traced = await traceReferences(call, {
|
||||
const traced = await traceReferences(call, observe, {
|
||||
matches: searched.value.matches,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
|
|
@ -59,8 +69,8 @@ export const traceLiteralFeature = async (
|
|||
search_mode: "literal",
|
||||
matches: searched.value.matches,
|
||||
references: traced.value.references,
|
||||
truncated: false,
|
||||
residual_unknowns: [],
|
||||
truncated: traced.value.residual.length > 0,
|
||||
residual_unknowns: traced.value.residual,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -126,24 +136,39 @@ const addPageMatches = (input: {
|
|||
|
||||
const traceReferences = async (
|
||||
call: AnalysisCall,
|
||||
observe: ObserveCall,
|
||||
request: ReferenceTraceRequest,
|
||||
): Promise<Result<{ references: TraceReference[] }, AnalysisError>> => {
|
||||
): Promise<
|
||||
Result<{ references: TraceReference[]; residual: string[] }, AnalysisError>
|
||||
> => {
|
||||
const references: TraceReference[] = [];
|
||||
const residual: string[] = [];
|
||||
// A source address is resolved once however many matches reference it.
|
||||
const containing = new Map<string, JsonValue>();
|
||||
for (const match of request.matches) {
|
||||
const xrefs = await call(
|
||||
const observed = await observe(
|
||||
"xrefs",
|
||||
{ address: match.address },
|
||||
request.signal,
|
||||
);
|
||||
if (!xrefs.ok) return xrefs;
|
||||
if (!Array.isArray(xrefs.value))
|
||||
if (!observed.ok) return observed;
|
||||
// Only an explicit producer report of truncation is a coverage gap; a
|
||||
// provider that reports nothing keeps its previous, unqualified result.
|
||||
const unknown = `References to ${match.address} were truncated by the provider; additional references may exist.`;
|
||||
if (
|
||||
observed.value.referencesTruncated === true &&
|
||||
!residual.includes(unknown)
|
||||
)
|
||||
residual.push(unknown);
|
||||
const sources = observed.value.result;
|
||||
if (!Array.isArray(sources))
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"xrefs",
|
||||
"provider returned a non-array result",
|
||||
),
|
||||
);
|
||||
for (const source of xrefs.value) {
|
||||
for (const source of sources) {
|
||||
if (typeof source !== "string")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
|
|
@ -151,18 +176,23 @@ const traceReferences = async (
|
|||
"provider returned a non-address value",
|
||||
),
|
||||
);
|
||||
const resolved = await call(
|
||||
"resolve_containing_procedure",
|
||||
{ address: source },
|
||||
request.signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
let containingProcedure = containing.get(source);
|
||||
if (containingProcedure === undefined) {
|
||||
const resolved = await call(
|
||||
"resolve_containing_procedure",
|
||||
{ address: source },
|
||||
request.signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
containingProcedure = resolved.value;
|
||||
containing.set(source, containingProcedure);
|
||||
}
|
||||
references.push({
|
||||
target_address: match.address,
|
||||
source_address: source,
|
||||
containing_procedure: resolved.value,
|
||||
containing_procedure: containingProcedure,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ok({ references });
|
||||
return ok({ references, residual });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ export class EnhancedTools {
|
|||
return traceLiteralFeature(
|
||||
(name, arguments_, operationSignal) =>
|
||||
this.#call(name, arguments_, operationSignal),
|
||||
(name, arguments_, operationSignal) =>
|
||||
this.#observe(name, arguments_, operationSignal),
|
||||
call.input,
|
||||
signal,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
editHermesConfigurationYaml,
|
||||
parseHermesConfigurationYaml,
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
|
||||
describe("Hermes configuration YAML", () => {
|
||||
it("writes an edited number from its new value", () => {
|
||||
expect(
|
||||
editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 384),
|
||||
).toBe("mode: 384\nmask: 0x1F\n");
|
||||
});
|
||||
|
||||
it("accepts keys Hermes reads as distinct and rejects a repeated key", () => {
|
||||
expect(() =>
|
||||
parseHermesConfigurationYaml("0600: octal\n600: decimal\n"),
|
||||
).not.toThrow();
|
||||
expect(() => parseHermesConfigurationYaml('a: 1\n"a": 2\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
isScalar,
|
||||
parse,
|
||||
parseDocument,
|
||||
type ParsedNode,
|
||||
type ScalarTag,
|
||||
type Tags,
|
||||
} from "yaml";
|
||||
|
||||
const NUMBER_TAGS = new Set([
|
||||
"tag:yaml.org,2002:int",
|
||||
"tag:yaml.org,2002:float",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Write a parsed number with its source spelling while that spelling still
|
||||
* reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact
|
||||
* integers, so re-printing a JavaScript number changes what it reads: a
|
||||
* Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and
|
||||
* `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float.
|
||||
*/
|
||||
const keepNumberSource = (tag: Tags[number]): Tags[number] => {
|
||||
if (typeof tag === "string" || tag.collection !== undefined) return tag;
|
||||
const { stringify } = tag;
|
||||
if (!NUMBER_TAGS.has(tag.tag) || stringify === undefined) return tag;
|
||||
return {
|
||||
...tag,
|
||||
stringify: (item, context, ...rest) => {
|
||||
const { source } = item;
|
||||
if (source === undefined) return stringify(item, context, ...rest);
|
||||
const reader = context.doc.schema.tags.find(
|
||||
(candidate): candidate is ScalarTag =>
|
||||
candidate.default === true && candidate.test?.test(source) === true,
|
||||
);
|
||||
const read = reader?.resolve(
|
||||
source,
|
||||
() => undefined,
|
||||
context.doc.options,
|
||||
);
|
||||
return Object.is(isScalar(read) ? read.value : read, item.value)
|
||||
? source
|
||||
: stringify(item, context, ...rest);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const HERMES_YAML_OPTIONS = {
|
||||
customTags: (tags: Tags) => tags.map(keepNumberSource),
|
||||
// Hermes keeps integers exact, so IDs that round to one double, or `0600`
|
||||
// and `600`, are distinct keys.
|
||||
uniqueKeys: (a: ParsedNode, b: ParsedNode) =>
|
||||
a === b ||
|
||||
(isScalar(a) &&
|
||||
isScalar(b) &&
|
||||
a.value === b.value &&
|
||||
a.source === b.source),
|
||||
};
|
||||
|
||||
/** Parse a Hermes `config.yaml`. */
|
||||
export const parseHermesConfigurationYaml = (text: string): unknown =>
|
||||
parse(text, HERMES_YAML_OPTIONS);
|
||||
|
||||
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
|
||||
export const editHermesConfigurationYaml = (
|
||||
text: string,
|
||||
path: readonly string[],
|
||||
value: unknown,
|
||||
): string => {
|
||||
const document = parseDocument(text, HERMES_YAML_OPTIONS);
|
||||
if (value === undefined) document.deleteIn(path);
|
||||
else document.setIn(path, value);
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return document.toString({ lineWidth: 0 });
|
||||
};
|
||||
|
|
@ -20,7 +20,9 @@ export const workflowAnalysisProfile = (
|
|||
upstream_analysis_profile: upstream,
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 3 }
|
||||
: operation === "batch_decompile" || operation === "get_call_graph"
|
||||
: operation === "batch_decompile" ||
|
||||
operation === "get_call_graph" ||
|
||||
operation === "trace_feature"
|
||||
? { workflow_revision: 2 }
|
||||
: {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,18 +97,22 @@ const resolveInternalSpecifier = (
|
|||
);
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
return { to: normalized, resolution: "unresolved" };
|
||||
// Preserve directory intent before normalization removes terminal dot segments.
|
||||
const directoryReference =
|
||||
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
|
||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||
const candidates = suffixes.flatMap((suffix) => [
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
...(directoryReference ? [] : [`${normalized}${suffix}`]),
|
||||
posix.join(normalized, `index${suffix}`),
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
if (!directoryReference)
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -157,8 +160,27 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
|
|||
const grokDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.GROK_HOME || join(home, ".grok");
|
||||
|
||||
/** Match Harness home-paths for both independent roots; trim only to detect blanks. */
|
||||
const dshDirectory = (
|
||||
home: string,
|
||||
override: string | undefined,
|
||||
fallback: string,
|
||||
): string => {
|
||||
const selected =
|
||||
override !== undefined && override.trim().length > 0
|
||||
? override
|
||||
: join(home, fallback);
|
||||
const expanded =
|
||||
selected === "~"
|
||||
? home
|
||||
: selected.startsWith("~/") || selected.startsWith("~\\")
|
||||
? join(home, selected.slice(2))
|
||||
: selected;
|
||||
return resolve(expanded);
|
||||
};
|
||||
|
||||
const dshHomeDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.DSH_HOME ?? join(home, ".dsh");
|
||||
dshDirectory(home, env.DSH_HOME, ".dsh");
|
||||
|
||||
const dshPatchDirectory = (context: ClientPathContext): string =>
|
||||
join(dshHomeDirectory(context), "cordis.patch.yml");
|
||||
|
|
@ -539,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) =>
|
||||
|
|
@ -555,7 +587,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||
// The home Cordis patch applies to every profile; the skill directory is
|
||||
// shared agent storage Harness discovers.
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.DSH_AGENTS_HOME ?? join(home, ".agents"), "skills"),
|
||||
join(dshDirectory(home, env.DSH_AGENTS_HOME, ".agents"), "skills"),
|
||||
configPath: dshPatchDirectory,
|
||||
markerPath: dshHomeDirectory,
|
||||
format: "dsh",
|
||||
|
|
|
|||
|
|
@ -661,3 +661,52 @@ it("does not replay pre-envelope call graphs under the revised workflow profile"
|
|||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("executions that report truncated references", () => {
|
||||
const record = (referencesTruncated: boolean | undefined) => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
const parameters = { address: "0x1000", document: "fixture" };
|
||||
cache.record({
|
||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||
profile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
operation: "xrefs",
|
||||
parameters,
|
||||
execution: createAnalysisExecution(
|
||||
["0x2000"],
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
subject: ANALYSIS_SNAPSHOT_TARGET,
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
...(referencesTruncated === undefined ? {} : { referencesTruncated }),
|
||||
},
|
||||
),
|
||||
});
|
||||
return cache.lookup(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
"xrefs",
|
||||
parameters,
|
||||
);
|
||||
};
|
||||
|
||||
it("are not replayed, because a replay would lose the truncation", () => {
|
||||
expect(record(true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("do not stop an execution that is complete or silent from being replayed", () => {
|
||||
expect(record(false)?.result).toEqual(["0x2000"]);
|
||||
expect(record(undefined)?.result).toEqual(["0x2000"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("revises the trace_feature workflow profile so earlier results are not replayed", () => {
|
||||
const profile = ANALYSIS_SNAPSHOT_PROFILE;
|
||||
|
||||
expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual(
|
||||
workflowAnalysisProfile(profile),
|
||||
);
|
||||
expect(
|
||||
workflowAnalysisProfile(profile, "trace_call_path"),
|
||||
"other workflows keep their profile",
|
||||
).toEqual(workflowAnalysisProfile(profile));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
@ -337,6 +342,9 @@ export class AnalysisSnapshotCache {
|
|||
}): void {
|
||||
const { target, profile, operation, parameters, execution } = input;
|
||||
this.select(target, profile);
|
||||
// The snapshot format does not carry reference truncation; a replay would
|
||||
// present a capped read as complete, so it is not recorded.
|
||||
if (execution.referencesTruncated === true) return;
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
snapshotBinding(profile),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ interface SessionBinding {
|
|||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
nativeSourceAdmitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -323,6 +324,7 @@ export class BinarySession
|
|||
profile,
|
||||
route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
this.resetSnapshotInvalidation();
|
||||
|
|
@ -609,6 +611,7 @@ export class BinarySession
|
|||
active: this.#active,
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
|
||||
unboundOperationError: (operation, route) =>
|
||||
this.#providerRouter.unboundOperationError(operation, route),
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
|
|
@ -645,6 +648,14 @@ export class BinarySession
|
|||
},
|
||||
})
|
||||
: profiled;
|
||||
// A successful Ghidra operation proves this client's native source snapshot
|
||||
// was admitted; a composite health response and parsed path do not.
|
||||
if (
|
||||
execution.ok &&
|
||||
capability.provider.id === "ghidra" &&
|
||||
this.#active === active
|
||||
)
|
||||
this.#active.nativeSourceAdmitted = true;
|
||||
this.#observeRuntimeAvailability(name, execution);
|
||||
if (
|
||||
execution.ok &&
|
||||
|
|
@ -838,6 +849,7 @@ export class BinarySession
|
|||
profile: previous.profile,
|
||||
route: previous.route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
return ok(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import type {
|
|||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js";
|
||||
import {
|
||||
isSnapshotCacheable,
|
||||
requiresNativeSourceAdmissionBeforeReplay,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
||||
interface ActiveExecutionBinding {
|
||||
|
|
@ -35,6 +38,7 @@ interface PrepareSessionExecutionInput {
|
|||
readonly active: ActiveExecutionBinding | undefined;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly nativeSourceAdmitted: boolean;
|
||||
readonly unboundOperationError: (
|
||||
operation: AnalysisOperation,
|
||||
route: SessionProviderRoute,
|
||||
|
|
@ -55,6 +59,7 @@ export const prepareSessionExecution = (
|
|||
active,
|
||||
operation,
|
||||
parameters,
|
||||
nativeSourceAdmitted,
|
||||
unboundOperationError,
|
||||
lookupSnapshot,
|
||||
} = input;
|
||||
|
|
@ -79,9 +84,12 @@ export const prepareSessionExecution = (
|
|||
const cacheable =
|
||||
profile !== undefined &&
|
||||
isSnapshotCacheable(operation, capability, parameters);
|
||||
const cached = cacheable
|
||||
? lookupSnapshot(active.target, profile, operation, parameters)
|
||||
: undefined;
|
||||
const cached =
|
||||
cacheable &&
|
||||
(!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) ||
|
||||
nativeSourceAdmitted)
|
||||
? lookupSnapshot(active.target, profile, operation, parameters)
|
||||
: undefined;
|
||||
return ok({ active, capability, profile, cacheable, cached });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,7 @@ export const resolveSessionTarget = async (
|
|||
input: ResolveSessionTargetInput,
|
||||
): Promise<Result<ResolvedSessionOpen, AnalysisError>> => {
|
||||
const { router, current, target, options } = input;
|
||||
const sameTarget =
|
||||
current?.target.path === target.path &&
|
||||
snapshotMatchesTarget(snapshotTarget(current.target), target);
|
||||
const sameTarget = sameSessionTarget(current?.target, target);
|
||||
const resolvedRoute =
|
||||
sameTarget && options.providerId === undefined && current !== undefined
|
||||
? ({ ok: true, value: current.route } as const)
|
||||
|
|
@ -113,9 +111,7 @@ export const validateResolvedSessionTarget = (
|
|||
},
|
||||
): Result<ResolvedSessionOpen, AnalysisError> => {
|
||||
const { current, target, route, options, stagedSnapshotMatches } = input;
|
||||
const sameTarget =
|
||||
current?.target.path === target.path &&
|
||||
snapshotMatchesTarget(snapshotTarget(current.target), target);
|
||||
const sameTarget = sameSessionTarget(current?.target, target);
|
||||
const snapshotError = validateSnapshot({
|
||||
snapshot: options.snapshot,
|
||||
target,
|
||||
|
|
@ -127,6 +123,16 @@ export const validateResolvedSessionTarget = (
|
|||
return { ok: true, value: { target, route, sameTarget } };
|
||||
};
|
||||
|
||||
const sameSessionTarget = (
|
||||
current: BinaryTarget | undefined,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
current !== undefined &&
|
||||
current.path === target.path &&
|
||||
// Native admission is bound to the requested path, not just its realpath.
|
||||
(current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) &&
|
||||
snapshotMatchesTarget(snapshotTarget(current), target);
|
||||
|
||||
interface ValidateSnapshotInput {
|
||||
readonly snapshot: AnalysisSnapshot | undefined;
|
||||
readonly target: BinaryTarget;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
|||
import { artifactCapabilities } from "./ArtifactProviderMetadata.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { freezeJsonSnapshot } from "../domain/immutableJson.js";
|
||||
import { createArtifactInspection } from "../domain/artifactInspection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
|
|
@ -341,7 +342,11 @@ class ArtifactClient implements AnalysisClient {
|
|||
total: 1,
|
||||
message: "inventory substep started",
|
||||
});
|
||||
const inventory = await this.inventory(inventoryParameters, options);
|
||||
// The freshly parsed inventory is exclusively owned here. Sealing it lets
|
||||
// the nested Evidence share it instead of cloning and re-digesting it.
|
||||
const inventory = freezeJsonSnapshot(
|
||||
await this.inventory(inventoryParameters, options),
|
||||
);
|
||||
const subject = subjectFor(
|
||||
this.target.sourcePath ?? this.target.path,
|
||||
inventory.manifest,
|
||||
|
|
@ -355,7 +360,10 @@ class ArtifactClient implements AnalysisClient {
|
|||
// The nested inventory already names every occurrence's logical path.
|
||||
locations: [],
|
||||
});
|
||||
const result = createArtifactInspection(inventoryEvidence);
|
||||
// The returned execution and its Evidence then reuse this owned report.
|
||||
const result = freezeJsonSnapshot(
|
||||
createArtifactInspection(inventoryEvidence),
|
||||
);
|
||||
await options?.progress?.report({
|
||||
phase: "inspect_artifact.inventory",
|
||||
completed: 1,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type NativeCommandRunner,
|
||||
} from "../native/CommandRunner.js";
|
||||
import {
|
||||
parseLipoArchitectures,
|
||||
parseLipoOutput,
|
||||
type LipoArchitecture,
|
||||
} from "../native/parsers/lipo.js";
|
||||
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
|
||||
|
|
@ -114,7 +114,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
};
|
||||
await this.#verifySource(source);
|
||||
const fileSize = source.initial.size;
|
||||
const architectures = parseLipoArchitectures(captured.value.stdout);
|
||||
const { architectures } = parseLipoOutput(captured.value.stdout, this.path);
|
||||
const structural = await readStructuralSlices(source.handle, fileSize);
|
||||
await this.#verifySource(source);
|
||||
if (structural.status === "malformed")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,29 @@ import type { FileHandle } from "node:fs/promises";
|
|||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { once } from "node:events";
|
||||
|
||||
import { Reader, ZipReader, type Entry, type FileEntry } from "@zip.js/zip.js";
|
||||
import {
|
||||
ERR_AMBIGUOUS_ARCHIVE,
|
||||
ERR_BAD_FORMAT,
|
||||
ERR_CENTRAL_DIRECTORY_NOT_FOUND,
|
||||
ERR_ENCRYPTED_CENTRAL_DIRECTORY,
|
||||
ERR_ENTRY_DATA_OUT_OF_BOUNDS,
|
||||
ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND,
|
||||
ERR_EOCDR_NOT_FOUND,
|
||||
ERR_EXTRAFIELD_ZIP64_NOT_FOUND,
|
||||
ERR_INVALID_COMPRESSED_DATA,
|
||||
ERR_INVALID_CRC32,
|
||||
ERR_INVALID_UNCOMPRESSED_SIZE,
|
||||
ERR_LOCAL_FILE_HEADER_NOT_FOUND,
|
||||
ERR_OVERLAPPING_ENTRY,
|
||||
ERR_SPLIT_ZIP_FILE,
|
||||
ERR_UNSUPPORTED_COMPRESSION,
|
||||
ERR_UNSUPPORTED_ENCRYPTION,
|
||||
ERR_UNSUPPORTED_UINT64,
|
||||
Reader,
|
||||
ZipReader,
|
||||
type Entry,
|
||||
type FileEntry,
|
||||
} from "@zip.js/zip.js";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
|
|
@ -159,9 +181,9 @@ export class ZipArtifactReader implements ArtifactReader {
|
|||
let index = 0;
|
||||
// A traversal owns its accounting, including overlapping or repeated passes.
|
||||
let declaredFileBytes = 0;
|
||||
for await (const entry of this.#reader.getEntriesGenerator({
|
||||
decodeText: decodeZipEntryText,
|
||||
})) {
|
||||
for await (const entry of typedZipFailures(
|
||||
this.#reader.getEntriesGenerator({ decodeText: decodeZipEntryText }),
|
||||
)) {
|
||||
abortIfNeeded(signal);
|
||||
// ZIP member identity is its directory occurrence, not its filename.
|
||||
// Logical path uniqueness belongs to the artifact inventory boundary.
|
||||
|
|
@ -327,7 +349,7 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => {
|
|||
output.end();
|
||||
},
|
||||
abort: (reason) => {
|
||||
output.destroy(toError(reason));
|
||||
output.destroy(toError(typedZipFailure(reason)));
|
||||
},
|
||||
});
|
||||
void entry
|
||||
|
|
@ -337,10 +359,61 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => {
|
|||
checkOverlappingEntry: true,
|
||||
onprogress: () => abortIfNeeded(signal),
|
||||
})
|
||||
.catch((cause: unknown) => output.destroy(toError(cause)));
|
||||
.catch((cause: unknown) => output.destroy(toError(typedZipFailure(cause))));
|
||||
return output;
|
||||
};
|
||||
|
||||
/** zip.js reports archive faults as plain errors identified by message. */
|
||||
const ZIP_FAILURE_REASONS: ReadonlyMap<string, "format" | "unavailable"> =
|
||||
new Map([
|
||||
...[
|
||||
ERR_AMBIGUOUS_ARCHIVE,
|
||||
ERR_BAD_FORMAT,
|
||||
ERR_CENTRAL_DIRECTORY_NOT_FOUND,
|
||||
ERR_ENTRY_DATA_OUT_OF_BOUNDS,
|
||||
ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND,
|
||||
ERR_EOCDR_NOT_FOUND,
|
||||
ERR_EXTRAFIELD_ZIP64_NOT_FOUND,
|
||||
ERR_INVALID_COMPRESSED_DATA,
|
||||
ERR_INVALID_CRC32,
|
||||
ERR_INVALID_UNCOMPRESSED_SIZE,
|
||||
ERR_LOCAL_FILE_HEADER_NOT_FOUND,
|
||||
ERR_OVERLAPPING_ENTRY,
|
||||
ERR_UNSUPPORTED_UINT64,
|
||||
].map((message) => [message, "format"] as const),
|
||||
...[
|
||||
ERR_ENCRYPTED_CENTRAL_DIRECTORY,
|
||||
ERR_SPLIT_ZIP_FILE,
|
||||
ERR_UNSUPPORTED_COMPRESSION,
|
||||
ERR_UNSUPPORTED_ENCRYPTION,
|
||||
].map((message) => [message, "unavailable"] as const),
|
||||
]);
|
||||
|
||||
/** Type a zip.js archive fault so consumers do not report it as file I/O. */
|
||||
const typedZipFailure = (cause: unknown): unknown => {
|
||||
if (!(cause instanceof Error) || cause instanceof ArtifactReaderFailure)
|
||||
return cause;
|
||||
const reason = ZIP_FAILURE_REASONS.get(cause.message);
|
||||
if (reason === undefined) return cause;
|
||||
return new ArtifactReaderFailure(
|
||||
reason,
|
||||
reason === "format"
|
||||
? `ZIP archive is malformed: ${cause.message}`
|
||||
: `ZIP archive uses an unsupported feature: ${cause.message}`,
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
|
||||
async function* typedZipFailures<T>(
|
||||
source: AsyncIterable<T>,
|
||||
): AsyncIterable<T> {
|
||||
try {
|
||||
yield* source;
|
||||
} catch (cause: unknown) {
|
||||
throw typedZipFailure(cause);
|
||||
}
|
||||
}
|
||||
|
||||
const isSymlink = (entry: Entry): boolean =>
|
||||
entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
|
||||
const fat = (wide = false): Buffer =>
|
||||
Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide));
|
||||
|
||||
describe("bounded FAT inventory classification", () => {
|
||||
it("requires the complete table but not slice bytes in the retained prefix", () => {
|
||||
const bytes = fat();
|
||||
for (const length of [4, 7, 8, 27]) {
|
||||
expect(
|
||||
classifyArtifactContent(
|
||||
"native",
|
||||
bytes.subarray(0, length),
|
||||
bytes.length,
|
||||
),
|
||||
).toEqual({ kind: "unknown", format: "unknown" });
|
||||
}
|
||||
expect(
|
||||
classifyArtifactContent(
|
||||
"native.class",
|
||||
bytes.subarray(0, 28),
|
||||
bytes.length,
|
||||
),
|
||||
).toEqual({ kind: "executable", format: "mach-o-universal" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty table", (bytes: Buffer) => bytes.writeUInt32BE(0, 4)],
|
||||
["slice overlapping table", (bytes: Buffer) => bytes.writeUInt32BE(0, 16)],
|
||||
["empty slice", (bytes: Buffer) => bytes.writeUInt32BE(0, 20)],
|
||||
[
|
||||
"slice beyond file",
|
||||
(bytes: Buffer) => bytes.writeUInt32BE(bytes.length, 20),
|
||||
],
|
||||
["unaligned slice", (bytes: Buffer) => bytes.writeUInt32BE(4097, 16)],
|
||||
["unsupported alignment", (bytes: Buffer) => bytes.writeUInt32BE(53, 24)],
|
||||
] as const)("keeps %s unknown", (_label, mutate) => {
|
||||
const bytes = fat();
|
||||
mutate(bytes);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer Mach-O from an out-of-bounds architecture count", () => {
|
||||
const bytes = fat();
|
||||
bytes.writeUInt32BE(0xffffffff, 4);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "resource",
|
||||
format: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not lose unsafe FAT64 range values to number conversion", () => {
|
||||
const bytes = fat(true);
|
||||
bytes.writeBigUInt64BE(BigInt(Number.MAX_SAFE_INTEGER) + 1n, 16);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires valid ranges for every declared slice", () => {
|
||||
const bytes = Buffer.from(
|
||||
fatImage([
|
||||
{ cpu: CPU.arm64, bytes: machoImage({}) },
|
||||
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
||||
]),
|
||||
);
|
||||
bytes.writeUInt32BE(bytes.length, 40);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires observed slice magic for an unknown CPU type", () => {
|
||||
const cpu = { type: 0x12345678, subtype: 0 };
|
||||
const bytes = Buffer.from(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
|
||||
expect(
|
||||
classifyArtifactContent("native", bytes.subarray(0, 28), bytes.length),
|
||||
).toEqual({ kind: "unknown", format: "unknown" });
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "executable",
|
||||
format: "mach-o-universal",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])(
|
||||
"keeps invalid or contradictory file size %s unknown",
|
||||
(size) => {
|
||||
const bytes = fat();
|
||||
expect(classifyArtifactContent("native", bytes, size)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ interface ExpectedFile {
|
|||
|
||||
interface ExpectedContainer {
|
||||
readonly hash_status: ArtifactOccurrence["hash_status"];
|
||||
readonly format: ArtifactOccurrence["artifact_format"];
|
||||
readonly inventory: JavaScriptArtifactContainer | null;
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +220,9 @@ const visitReader = async (
|
|||
"integrity",
|
||||
`Nested ASAR was not integrity-verified: ${path}`,
|
||||
);
|
||||
// Inventory expands an ASAR-named file only when its bytes are an
|
||||
// ASAR. One it recorded as ordinary bytes has no members to read.
|
||||
if (container.format !== "asar") continue;
|
||||
const inventory = container.inventory;
|
||||
if (inventory === null)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
|
@ -395,6 +399,7 @@ const expectedInventory = (
|
|||
if (isNestedAsar)
|
||||
containers.set(occurrence.logical_path, {
|
||||
hash_status: occurrence.hash_status,
|
||||
format: occurrence.artifact_format,
|
||||
inventory: null,
|
||||
});
|
||||
continue;
|
||||
|
|
@ -412,6 +417,7 @@ const expectedInventory = (
|
|||
if (isNestedAsar)
|
||||
containers.set(occurrence.logical_path, {
|
||||
hash_status: occurrence.hash_status,
|
||||
format: occurrence.artifact_format,
|
||||
inventory: {
|
||||
path: occurrence.logical_path,
|
||||
sha256: node.sha256,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureDom } from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const documentUrl = `${origin}/screens/app`;
|
||||
|
|
@ -50,8 +52,11 @@ const snapshot = (
|
|||
],
|
||||
});
|
||||
|
||||
const capture = (value: ReturnType<typeof snapshot>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const capture = (value: ReturnType<typeof snapshot>) => {
|
||||
const step = captureDom(value, new Set([origin]), input).next();
|
||||
if (!step.done) throw new Error("Unexpected SVG identity query");
|
||||
return step.value;
|
||||
};
|
||||
|
||||
describe("DOM metadata document base URLs", () => {
|
||||
it("resolves links, actions and agent hints against the captured document base", () => {
|
||||
|
|
@ -282,6 +287,7 @@ interface SvgSnapshotNode {
|
|||
readonly name: string;
|
||||
readonly parent: number;
|
||||
readonly attributes?: readonly (readonly [string, string])[];
|
||||
readonly svg?: boolean;
|
||||
}
|
||||
|
||||
const svgDocument = (
|
||||
|
|
@ -300,7 +306,13 @@ const svgDocument = (
|
|||
const nodeValue = [intern("")];
|
||||
const parentIndex = [-1];
|
||||
const attributes: number[][] = [[]];
|
||||
const backendNodeId = [1];
|
||||
const svgNodeKinds = new Map<string, boolean>();
|
||||
for (const element of elements) {
|
||||
const id = backendNodeId.length + 1;
|
||||
backendNodeId.push(id);
|
||||
if (element.svg !== undefined)
|
||||
svgNodeKinds.set(`${String(id)}:${element.name}`, element.svg);
|
||||
nodeType.push(1);
|
||||
nodeName.push(intern(element.name));
|
||||
nodeValue.push(intern(""));
|
||||
|
|
@ -316,14 +328,35 @@ const svgDocument = (
|
|||
{
|
||||
documentURL: 0,
|
||||
baseURL: 1,
|
||||
nodes: { nodeType, nodeName, nodeValue, parentIndex, attributes },
|
||||
nodes: {
|
||||
nodeType,
|
||||
nodeName,
|
||||
nodeValue,
|
||||
parentIndex,
|
||||
attributes,
|
||||
backendNodeId,
|
||||
},
|
||||
svgNodeKinds,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) => {
|
||||
const kinds = new Map(
|
||||
value.documents.flatMap((document) => [...document.svgNodeKinds]),
|
||||
);
|
||||
const capture = captureDom(value, new Set([origin]), input);
|
||||
let step = capture.next();
|
||||
while (!step.done) {
|
||||
step = capture.next(
|
||||
step.value.map((query) =>
|
||||
kinds.get(`${String(query.backendNodeId)}:${query.nodeName}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const urlFor = (
|
||||
result: ReturnType<typeof captureSvg>,
|
||||
|
|
@ -331,6 +364,136 @@ const urlFor = (
|
|||
): (typeof result.urls)[number] | undefined =>
|
||||
result.urls.find((url) => url.node_index === nodeIndex);
|
||||
|
||||
describe("browser SVG identity", () => {
|
||||
const query = { backendNodeId: 42, nodeName: "a" };
|
||||
const node = { ...query, nodeType: 1 };
|
||||
|
||||
it("uses only a matching described element's SVG flag", () => {
|
||||
expect(capturedSvgNodeKind(query, { node: { ...node, isSVG: true } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
capturedSvgNodeKind(query, { node: { ...node, isSVG: false } }),
|
||||
).toBe(false);
|
||||
expect(capturedSvgNodeKind(query, { node })).toBe(false);
|
||||
for (const invalid of [
|
||||
{},
|
||||
{ node: { ...node, backendNodeId: 43, isSVG: true } },
|
||||
{ node: { ...node, nodeName: "A", isSVG: true } },
|
||||
{ node: { ...node, nodeType: 3, isSVG: true } },
|
||||
{ node: { ...node, isSVG: "true" } },
|
||||
])
|
||||
expect(capturedSvgNodeKind(query, invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("distinguishes identically named SVG and MathML elements regardless of parent", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "desc", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/math"]],
|
||||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/html"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/orphan"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls.map(({ url }) => url)).toEqual([
|
||||
`${origin}/svg`,
|
||||
`${origin}/orphan`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("records unresolved identity without inventing a destination or hiding attributes", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/unknown"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(value, new Set([origin]), input, completeness);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [{ backendNodeId: 2, nodeName: "a" }],
|
||||
});
|
||||
const step = capture.next([undefined]);
|
||||
expect(step.done).toBe(true);
|
||||
if (!step.done) throw new Error("Unexpected second query");
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(step.value.urls).toEqual([]);
|
||||
expect(step.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]);
|
||||
expect(completeness.snapshot().excluded).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps batched SVG identities aligned around missing IDs and ordinary links", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/svg"]] },
|
||||
{ name: "A", parent: 0, attributes: [["href", "/ordinary"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/missing-id"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/non-svg"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(
|
||||
{
|
||||
...value,
|
||||
documents: value.documents.map((document) => ({
|
||||
...document,
|
||||
nodes: { ...document.nodes, backendNodeId: [1, 2, 3, null, 5] },
|
||||
})),
|
||||
},
|
||||
new Set([origin]),
|
||||
input,
|
||||
completeness,
|
||||
);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [
|
||||
{ backendNodeId: 2, nodeName: "a" },
|
||||
{ backendNodeId: 5, nodeName: "a" },
|
||||
],
|
||||
});
|
||||
const step = capture.next([true, false]);
|
||||
if (!step.done) throw new Error("Unexpected second batch");
|
||||
expect(
|
||||
step.value.urls.map(({ node_index, url }) => [node_index, url]),
|
||||
).toEqual([
|
||||
[1, `${origin}/svg`],
|
||||
[2, `${origin}/ordinary`],
|
||||
]);
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(completeness.snapshot().excluded).toContainEqual({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG xlink:href destinations", () => {
|
||||
it("reports an SVG anchor that only has xlink:href, keeping the first value", () => {
|
||||
const result = captureSvg(
|
||||
|
|
@ -339,6 +502,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "g", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/xl-s2h"],
|
||||
|
|
@ -371,6 +535,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
[first, valueFor(first)],
|
||||
|
|
@ -395,18 +560,21 @@ describe("SVG xlink:href destinations", () => {
|
|||
svgDocument([
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/xl-html"]],
|
||||
},
|
||||
{ name: "math", parent: 0 },
|
||||
{
|
||||
name: "mrow",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-math"]],
|
||||
},
|
||||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-s2h"]],
|
||||
},
|
||||
|
|
@ -425,6 +593,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "https://cdn.example.test/out"]],
|
||||
},
|
||||
|
|
@ -444,8 +613,18 @@ describe("SVG xlink:href destinations", () => {
|
|||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "image", parent: 1, attributes: [["xlink:href", "guide"]] },
|
||||
{ name: "use", parent: 1, attributes: [["xlink:href", "icon.svg"]] },
|
||||
{
|
||||
name: "image",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "guide"]],
|
||||
},
|
||||
{
|
||||
name: "use",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "icon.svg"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([
|
||||
|
|
@ -466,14 +645,24 @@ describe("SVG xlink:href destinations", () => {
|
|||
});
|
||||
|
||||
describe("SVG xlink:href context", () => {
|
||||
it("keeps forward ancestor context local to each document", () => {
|
||||
it("binds SVG identity to the selected element across documents", () => {
|
||||
const svg = svgDocument([
|
||||
{ name: "a", parent: 2, attributes: [["xlink:href", "/svg-link"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg-link"]],
|
||||
},
|
||||
{ name: "g", parent: 3 },
|
||||
{ name: "svg", parent: 0 },
|
||||
]);
|
||||
const html = svgDocument([
|
||||
{ name: "A", parent: 2, attributes: [["xlink:href", "/html-link"]] },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/html-link"]],
|
||||
},
|
||||
{ name: "DIV", parent: 3 },
|
||||
{ name: "BODY", parent: 0 },
|
||||
]);
|
||||
|
|
@ -507,32 +696,39 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("stops SVG context at foreignObject and resumes it for a nested svg", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "foreignObject", parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
});
|
||||
it.each(["foreignObject", "desc", "title"])(
|
||||
"preserves browser SVG identity across %s HTML integration points",
|
||||
(integrationPoint) => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: integrationPoint, parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("SVG XLink precedence and unknown identity", () => {
|
||||
it("keeps an empty href ahead of xlink:href", () => {
|
||||
const baseUrl = `${origin}/screens/assets/`;
|
||||
const result = captureSvg(
|
||||
|
|
@ -541,6 +737,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
["href", ""],
|
||||
|
|
@ -567,6 +764,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "javascript:void(0)"]],
|
||||
},
|
||||
|
|
@ -582,7 +780,7 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("does not treat a cyclic or dangling parent chain as SVG", () => {
|
||||
it("keeps an unavailable SVG identity unknown despite ancestor names", () => {
|
||||
const cyclic = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "g", parent: 2 },
|
||||
|
|
@ -652,7 +850,12 @@ describe("XML SVG roots and namespace scope restoration", () => {
|
|||
["xmlns:p", XLINK_NAMESPACE],
|
||||
],
|
||||
},
|
||||
{ name: "a", parent: 1, attributes: [["p:href", "/linked"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["p:href", "/linked"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([approvedHref(2, `${origin}/linked`)]);
|
||||
|
|
@ -764,6 +967,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
{ name: "svg:foreignObject", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 3,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
|
|
@ -799,6 +1003,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/not-xlink"],
|
||||
|
|
@ -918,11 +1123,13 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-literal"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["x:href", "/xl-custom-html"]],
|
||||
},
|
||||
|
|
@ -937,6 +1144,7 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/xl-html-anchor"]],
|
||||
},
|
||||
|
|
@ -944,7 +1152,9 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
);
|
||||
expect(result.urls).toEqual([approvedHref(3, `${origin}/xl-literal`)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XML namespace resolution ordering and capacity", () => {
|
||||
it("applies an element's own xmlns declarations to its name and href", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
} from "./CdpCaptureValues.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
createDocumentSvgAncestry,
|
||||
domSvgLink,
|
||||
type DomSvgNodeQuery,
|
||||
} from "./CdpCaptureDomSvg.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
|
|
@ -135,18 +139,39 @@ export const walkFrameTrees = function* (
|
|||
}
|
||||
};
|
||||
|
||||
export const captureDom = (
|
||||
interface DomMetadataCapture {
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
readonly unresolvedSvgNodes: number;
|
||||
}
|
||||
|
||||
export interface DomCapture extends DomMetadataCapture {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
}
|
||||
|
||||
// Bound retained projections and outstanding CDP reads independently of page size.
|
||||
// Eight leaves room for the session's other cancellation listeners.
|
||||
const DOM_METADATA_BATCH_SIZE = 8;
|
||||
|
||||
interface PendingDomMetadata {
|
||||
readonly options: DomMetadataOptions;
|
||||
// Undefined means no query is needed; null means the candidate has no valid ID.
|
||||
readonly svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
}
|
||||
|
||||
/** Project one snapshot, requesting bounded batches of SVG identity facts. */
|
||||
export function* captureDom(
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
input: InspectWebPageInput,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
} => {
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
||||
|
|
@ -156,6 +181,7 @@ export const captureDom = (
|
|||
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
||||
let excludedUrls = 0;
|
||||
let total = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
|
|
@ -172,6 +198,9 @@ export const captureDom = (
|
|||
const attributes = Array.isArray(documentNodes.attributes)
|
||||
? documentNodes.attributes
|
||||
: [];
|
||||
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
|
||||
? documentNodes.backendNodeId
|
||||
: [];
|
||||
const baseIndex = nodes.length;
|
||||
const ancestry = createDocumentSvgAncestry({
|
||||
nodeTypes,
|
||||
|
|
@ -181,12 +210,29 @@ export const captureDom = (
|
|||
attributes,
|
||||
});
|
||||
total += nodeTypes.length;
|
||||
const pendingMetadata: PendingDomMetadata[] = [];
|
||||
for (let index = 0; index < nodeTypes.length; index += 1) {
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]);
|
||||
const attributeNames = uniqueAttributeNames(attributeIndexes, strings);
|
||||
const svgLink = domSvgLink(ancestry, index);
|
||||
let svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
if (
|
||||
!svgLink.xmlDocument &&
|
||||
nodeTypes[index] === 1 &&
|
||||
attributeNames.includes("xlink:href") &&
|
||||
!attributeNames.includes("href")
|
||||
) {
|
||||
const backendNodeId = numberValue(backendNodeIds[index]);
|
||||
svgQuery =
|
||||
backendNodeId !== undefined &&
|
||||
Number.isSafeInteger(backendNodeId) &&
|
||||
backendNodeId > 0
|
||||
? { backendNodeId, nodeName }
|
||||
: null;
|
||||
}
|
||||
nodes.push({
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
|
|
@ -195,30 +241,34 @@ export const captureDom = (
|
|||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
attribute_names: attributeNames,
|
||||
});
|
||||
const metadata = domMetadata({
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...domSvgLink(ancestry, index),
|
||||
pendingMetadata.push({
|
||||
svgQuery,
|
||||
options: {
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...svgLink,
|
||||
},
|
||||
});
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
if (
|
||||
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
|
||||
index === nodeTypes.length - 1
|
||||
) {
|
||||
const metadata = yield* captureDomMetadata(
|
||||
pendingMetadata,
|
||||
completeness,
|
||||
);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -227,8 +277,54 @@ export const captureDom = (
|
|||
urls,
|
||||
agentHints,
|
||||
excludedUrls,
|
||||
unresolvedSvgNodes,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function* captureDomMetadata(
|
||||
pending: readonly PendingDomMetadata[],
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomMetadataCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const queries = pending.flatMap(({ svgQuery }) =>
|
||||
svgQuery == null ? [] : [svgQuery],
|
||||
);
|
||||
const kinds = queries.length > 0 ? yield queries : [];
|
||||
const urls: DomMetadataCapture["urls"] = [];
|
||||
const agentHints: DomMetadataCapture["agentHints"] = [];
|
||||
let queryIndex = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
let excludedUrls = 0;
|
||||
for (const { options, svgQuery } of pending) {
|
||||
let svgElement = options.svgElement;
|
||||
if (svgQuery !== undefined) {
|
||||
const kind = svgQuery === null ? undefined : kinds[queryIndex++];
|
||||
svgElement = kind === true;
|
||||
if (kind === undefined) {
|
||||
unresolvedSvgNodes += 1;
|
||||
completeness?.exclude("metadata", "provider_unavailable");
|
||||
}
|
||||
}
|
||||
const metadata = domMetadata({ ...options, svgElement });
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
|
||||
}
|
||||
|
||||
export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { numberValue } from "./CdpCaptureValues.js";
|
||||
import { numberValue, recordValue } from "./CdpCaptureValues.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
|
|
@ -18,11 +18,34 @@ interface XmlSvgLink {
|
|||
|
||||
interface DocumentSvgAncestry {
|
||||
readonly xml: boolean;
|
||||
readonly nodes: DomSnapshotNodes;
|
||||
readonly svgContexts: Map<number, boolean>;
|
||||
readonly xmlLinks: Map<number, XmlSvgLink>;
|
||||
}
|
||||
|
||||
export interface DomSvgNodeQuery {
|
||||
readonly backendNodeId: number;
|
||||
readonly nodeName: string;
|
||||
}
|
||||
|
||||
/** Bind the browser's SVG flag to the exact snapshotted element. */
|
||||
export const capturedSvgNodeKind = (
|
||||
query: DomSvgNodeQuery,
|
||||
result: unknown,
|
||||
): boolean | undefined => {
|
||||
const node = recordValue(recordValue(result)?.node);
|
||||
if (
|
||||
node?.backendNodeId !== query.backendNodeId ||
|
||||
node.nodeName !== query.nodeName ||
|
||||
node.nodeType !== 1
|
||||
)
|
||||
return undefined;
|
||||
// CDP omits isSVG for non-SVG nodes. Other values are malformed, not false.
|
||||
return node.isSVG === undefined
|
||||
? false
|
||||
: typeof node.isSVG === "boolean"
|
||||
? node.isSVG
|
||||
: undefined;
|
||||
};
|
||||
|
||||
interface DomSvgLink {
|
||||
readonly svgElement: boolean;
|
||||
readonly xmlDocument: boolean;
|
||||
|
|
@ -79,8 +102,6 @@ export const createDocumentSvgAncestry = (
|
|||
const xml = xmlDocument(nodes);
|
||||
return {
|
||||
xml,
|
||||
nodes,
|
||||
svgContexts: new Map(),
|
||||
xmlLinks: xml ? resolveXmlElements(nodes) : new Map(),
|
||||
};
|
||||
};
|
||||
|
|
@ -91,11 +112,7 @@ export const domSvgLink = (
|
|||
): DomSvgLink => {
|
||||
if (!ancestry.xml) {
|
||||
return {
|
||||
svgElement: elementInSvgContext(
|
||||
index,
|
||||
ancestry.nodes,
|
||||
ancestry.svgContexts,
|
||||
),
|
||||
svgElement: false,
|
||||
xmlDocument: false,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
|
|
@ -108,43 +125,6 @@ export const domSvgLink = (
|
|||
};
|
||||
};
|
||||
|
||||
// Chrome's HTML parser records SVG local names (`svg`, `foreignObject`).
|
||||
// Descendants of foreignObject are HTML until a nested `svg`.
|
||||
const elementInSvgContext = (
|
||||
index: number,
|
||||
nodes: DomSnapshotNodes,
|
||||
contexts: Map<number, boolean>,
|
||||
): boolean => {
|
||||
let current = index;
|
||||
const seen = new Set<number>();
|
||||
let inSvg = false;
|
||||
while (
|
||||
current >= 0 &&
|
||||
current < nodes.nodeNames.length &&
|
||||
!seen.has(current)
|
||||
) {
|
||||
const cached = contexts.get(current);
|
||||
if (cached !== undefined) {
|
||||
inSvg = cached;
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[current]) === "svg") {
|
||||
inSvg = true;
|
||||
break;
|
||||
}
|
||||
const parent = Math.trunc(nodes.parents[current] ?? -1);
|
||||
if (parent < 0 || parent === current) break;
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[parent]) === "foreignObject")
|
||||
break;
|
||||
current = parent;
|
||||
}
|
||||
// Cache the entire examined chain, including forward references and cycles,
|
||||
// so shared ancestors are examined once per document.
|
||||
for (const node of seen) contexts.set(node, inSvg);
|
||||
return inSvg;
|
||||
};
|
||||
|
||||
interface NamespaceRestore {
|
||||
readonly prefix: string;
|
||||
readonly previous: string | undefined;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import {
|
|||
captureFrames,
|
||||
captureResources,
|
||||
type CapturedResource,
|
||||
type DomCapture,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
|
|
@ -52,7 +54,7 @@ interface CaptureState {
|
|||
interface CapturedSections {
|
||||
readonly attachedUrl: string;
|
||||
readonly frames: WebPageInspection["frames"];
|
||||
readonly dom: ReturnType<typeof captureDom>;
|
||||
readonly dom: DomCapture;
|
||||
readonly accessibility: ReturnType<typeof captureAccessibility>;
|
||||
readonly scripts: WebPageInspection["scripts"];
|
||||
readonly resources: WebPageInspection["resources"];
|
||||
|
|
@ -260,9 +262,7 @@ const authorizeObservationWindow = async (
|
|||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const capturePageDom = async (
|
||||
state: CaptureState,
|
||||
): Promise<ReturnType<typeof captureDom>> => {
|
||||
const capturePageDom = async (state: CaptureState): Promise<DomCapture> => {
|
||||
const { context, allowedOrigins } = state;
|
||||
const result = await context.connection.send(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
|
|
@ -274,12 +274,56 @@ const capturePageDom = async (
|
|||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
return captureDom(
|
||||
const capture = captureDom(
|
||||
result,
|
||||
allowedOrigins,
|
||||
context.input,
|
||||
state.events.completeness,
|
||||
);
|
||||
let step = capture.next();
|
||||
let descriptionUnavailable = false;
|
||||
const pendingController = new AbortController();
|
||||
const signal =
|
||||
context.signal === undefined
|
||||
? pendingController.signal
|
||||
: AbortSignal.any([context.signal, pendingController.signal]);
|
||||
while (!step.done) {
|
||||
const pending = step.value.map(async (query) => {
|
||||
if (descriptionUnavailable) return undefined;
|
||||
try {
|
||||
return capturedSvgNodeKind(
|
||||
query,
|
||||
await context.connection.send(
|
||||
"DOM.describeNode",
|
||||
{ backendNodeId: query.backendNodeId, depth: 0 },
|
||||
context.sessionId,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// A detached node can disappear between snapshot and description.
|
||||
// Preserve partial metadata, but propagate transport/cancellation errors.
|
||||
if (!(cause instanceof CdpCommandRejection)) throw cause;
|
||||
if (cause.code === -32_601) descriptionUnavailable = true;
|
||||
else if (cause.code !== -32_000) throw cause;
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Promise.all preserves snapshot order even when CDP replies out of order.
|
||||
step = capture.next(await Promise.all(pending));
|
||||
} catch (cause: unknown) {
|
||||
// Stop and settle this bounded batch before the session can be detached.
|
||||
pendingController.abort();
|
||||
await Promise.allSettled(pending);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
if (step.value.unresolvedSvgNodes > 0)
|
||||
state.limitations.push(
|
||||
`SVG identity was unavailable for ${String(step.value.unresolvedSvgNodes)} snapshotted XLink candidate nodes; their URL destinations remain unknown.`,
|
||||
);
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const normalizedInspection = (
|
||||
|
|
|
|||
|
|
@ -216,8 +216,9 @@ export const createCliResultOutput = (
|
|||
: filtered === undefined
|
||||
? { ok: true, meta }
|
||||
: { ok: true, data: filtered, meta }
|
||||
: filtered === undefined ||
|
||||
filtered === null ||
|
||||
: // A selected null is a value; only a selection with no JSON value
|
||||
// falls back to the empty document.
|
||||
filtered === undefined ||
|
||||
typeof filtered === "function" ||
|
||||
typeof filtered === "symbol"
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export type CliOutputArgumentValidation =
|
|||
| {
|
||||
readonly ok: false;
|
||||
readonly format: StructuredOutputFormat;
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION";
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION" | "INVALID_OUTPUT_FILTER";
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
|
|
@ -89,13 +89,39 @@ export const parseCliOutputArguments = (
|
|||
return parsed(false);
|
||||
};
|
||||
|
||||
/** Reject text-window controls that would corrupt a structured document. */
|
||||
/** Reject slices that cannot advance within Incur's comma-delimited paths. */
|
||||
const hasUnclosedFilterSlice = (expression: string): boolean => {
|
||||
let depth = 0;
|
||||
let unclosed = false;
|
||||
for (const character of expression) {
|
||||
if (character === "[") {
|
||||
depth += 1;
|
||||
unclosed = true;
|
||||
} else if (character === "]") {
|
||||
depth -= 1;
|
||||
// Incur consumes everything through the next closing bracket, including
|
||||
// nested opening brackets. Do not impose a different filter grammar.
|
||||
unclosed = false;
|
||||
} else if (character === "," && depth === 0 && unclosed) return true;
|
||||
}
|
||||
return unclosed;
|
||||
};
|
||||
|
||||
/** Reject output controls that cannot safely produce a complete result. */
|
||||
export const validateCliOutputArguments = (
|
||||
arguments_: readonly string[],
|
||||
): CliOutputArgumentValidation => {
|
||||
const { format, parseError, tokenWindow } =
|
||||
const { filterOutput, format, parseError, tokenWindow } =
|
||||
parseCliOutputArguments(arguments_);
|
||||
if (parseError) return { ok: true };
|
||||
if (filterOutput !== undefined && hasUnclosedFilterSlice(filterOutput))
|
||||
return {
|
||||
ok: false,
|
||||
format: format === "yaml" || format === "jsonl" ? format : "json",
|
||||
code: "INVALID_OUTPUT_FILTER",
|
||||
message:
|
||||
"The --filter-output expression has a slice without a closing ]. Close the slice or remove --filter-output.",
|
||||
};
|
||||
if (
|
||||
tokenWindow &&
|
||||
(format === "json" || format === "jsonl" || format === "yaml")
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const NATIVE_TOOL_CONTRACTS = [
|
|||
),
|
||||
native(
|
||||
"inspect_signature",
|
||||
"Inspect the active artifact's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.",
|
||||
"Inspect the active Mach-O executable's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.",
|
||||
z.object({}),
|
||||
),
|
||||
native(
|
||||
|
|
|
|||
|
|
@ -149,12 +149,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
|||
),
|
||||
official(
|
||||
"procedure_callees",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty.",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
"procedure_callers",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references.",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
|
||||
z.object({ procedure, document }),
|
||||
),
|
||||
official(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ import { z } from "zod";
|
|||
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"advertises the raw-only 6502 Evidence subject constraint in %s",
|
||||
(target) => {
|
||||
const valid = createEvidence(
|
||||
{
|
||||
path: "/fixture/image.bin",
|
||||
sha256: "a".repeat(64),
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
},
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "address_name", parameters: {}, result: "entry" },
|
||||
);
|
||||
const advertised = toolOutputSchemaWithMetadata(
|
||||
toolContract("address_name"),
|
||||
)["~standard"].jsonSchema.output({ target });
|
||||
const validate = new AjvJsonSchemaValidator().getValidator(advertised);
|
||||
expect(validate(valid).valid).toBe(true);
|
||||
expect(
|
||||
validate({
|
||||
...valid,
|
||||
subject: { ...valid.subject, format: "pe" },
|
||||
}).valid,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps dialect-specific validators distinct after JSON schema serialization", () => {
|
||||
const contract = {
|
||||
|
|
|
|||
|
|
@ -444,11 +444,11 @@ const lifecycleTargetFields = {
|
|||
path: z.string(),
|
||||
kind: targetKindSchema,
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
};
|
||||
const detectedLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
format: targetFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
|
||||
});
|
||||
const rawLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const snapshotWithHistoricalEvidence = () => {
|
|||
};
|
||||
return {
|
||||
current,
|
||||
snapshot: {
|
||||
snapshot: analysisSnapshotSchema.parse({
|
||||
target,
|
||||
binding,
|
||||
entries: [entry],
|
||||
|
|
@ -66,11 +66,32 @@ const snapshotWithHistoricalEvidence = () => {
|
|||
result: { summary: "unprofiled" },
|
||||
}),
|
||||
]),
|
||||
} satisfies AnalysisSnapshot,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("analysis snapshot target identity", () => {
|
||||
it("keeps 6502 confined to executable raw snapshots", () => {
|
||||
const target = {
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
kind: "executable",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "pe",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "raw-binary",
|
||||
entry_point: "0x8001",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("partitions raw snapshots and query identities by the canonical entry", () => {
|
||||
const target: BinaryTarget = {
|
||||
path: "/fixture/image.bin",
|
||||
|
|
@ -120,6 +141,48 @@ describe("analysis snapshot target identity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("analysis snapshot nested subject contract", () => {
|
||||
it("rejects raw-only 6502 in nested detected execution subjects", () => {
|
||||
const { snapshot } = snapshotWithHistoricalEvidence();
|
||||
const entry = snapshot.entries[0];
|
||||
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
|
||||
const malformedSubject = {
|
||||
...entry.execution.subject,
|
||||
format: "pe",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
analysis_profile: snapshot.binding.analysis_profile,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis snapshot contract", () => {
|
||||
it.each([
|
||||
["dos-mz", "x86"],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ import { digestSchema } from "./../domain/digests.js";
|
|||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
import { rawBinaryEntryPointSchema } from "./rawBinary.js";
|
||||
import {
|
||||
binaryArchitectureSchema,
|
||||
rawBinaryEntryPointSchema,
|
||||
} from "./rawBinary.js";
|
||||
import {
|
||||
relationshipCoverageSchema,
|
||||
type RelationshipCoverage,
|
||||
|
|
@ -73,20 +76,30 @@ const formatSchema = z.enum([
|
|||
"source-map",
|
||||
]);
|
||||
const kindSchema = z.enum(["executable", "database", "archive", "artifact"]);
|
||||
const subjectSchema = z.object({
|
||||
const subjectBaseSchema = z.object({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
format: z.enum([
|
||||
...formatSchema.options,
|
||||
"hopper",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]),
|
||||
architecture: architectureSchema.nullable(),
|
||||
});
|
||||
const subjectFormatSchema = z.enum([
|
||||
...formatSchema.options,
|
||||
"hopper",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]);
|
||||
// Snapshot entries retain the same raw-only architecture boundary as targets.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: architectureSchema.nullable(),
|
||||
}),
|
||||
]);
|
||||
const targetIdentitySchema = z.object({
|
||||
sha256: digestSchema,
|
||||
kind: kindSchema,
|
||||
|
|
@ -97,9 +110,12 @@ const targetSchema = z.discriminatedUnion("format", [
|
|||
format: z.enum(
|
||||
formatSchema.options.filter((format) => format !== "raw-binary"),
|
||||
),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
}),
|
||||
targetIdentitySchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
kind: z.literal("executable"),
|
||||
architecture: binaryArchitectureSchema,
|
||||
entry_point: rawBinaryEntryPointSchema,
|
||||
}),
|
||||
]);
|
||||
|
|
@ -332,18 +348,21 @@ const canonicalJson = (value: unknown): string => {
|
|||
};
|
||||
|
||||
/** Project a binary into the path-free identity used for cache invalidation. */
|
||||
export const snapshotTarget = (
|
||||
target: BinaryTarget,
|
||||
): AnalysisSnapshotTarget => {
|
||||
const identity = {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
};
|
||||
return target.format === "raw-binary"
|
||||
? { ...identity, format: target.format, entry_point: target.entryPoint }
|
||||
: { ...identity, format: target.format };
|
||||
};
|
||||
export const snapshotTarget = (target: BinaryTarget): AnalysisSnapshotTarget =>
|
||||
target.format === "raw-binary"
|
||||
? {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture,
|
||||
format: target.format,
|
||||
entry_point: target.entryPoint,
|
||||
}
|
||||
: {
|
||||
sha256: target.sha256,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
format: target.format,
|
||||
};
|
||||
|
||||
/** Build the immutable provider/profile binding used by a cached snapshot. */
|
||||
export const snapshotBinding = (
|
||||
|
|
@ -475,10 +494,10 @@ export const createAnalysisSnapshotWorkflowEntry = (input: {
|
|||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
: subjectSchema.parse({
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -538,10 +557,10 @@ export const createAnalysisSnapshotEntry = (input: {
|
|||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
: subjectSchema.parse({
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -169,7 +169,8 @@ it("pages every retained dossier collection and the native API record", () => {
|
|||
| "comments"
|
||||
| "unresolved_calls"
|
||||
| "referenced_strings"
|
||||
| "referenced_names",
|
||||
| "referenced_names"
|
||||
| "swift_string_literals",
|
||||
result: unknown = original,
|
||||
) => {
|
||||
const view = projectAnalysisView(nativeParent(result), {
|
||||
|
|
@ -195,6 +196,23 @@ it("pages every retained dossier collection and the native API record", () => {
|
|||
}),
|
||||
).toMatchObject({ item: [{ text: "entry" }] });
|
||||
expect(page("referenced_names")).toMatchObject({ coverage: { total: 0 } });
|
||||
const literal = {
|
||||
source_address: "0x401004",
|
||||
value: "SleepDisabled",
|
||||
representation: "small",
|
||||
address: null,
|
||||
registers: ["x0", "x1"],
|
||||
words: ["0x7369447065656c53", "0xed000064656c6261"],
|
||||
};
|
||||
expect(
|
||||
page("swift_string_literals", {
|
||||
...original,
|
||||
swift_string_literals: [literal],
|
||||
}),
|
||||
).toMatchObject({ item: [literal], coverage: { total: 1 } });
|
||||
expect(page("swift_string_literals")).toMatchObject({
|
||||
item: { available: false, reason: "not recorded" },
|
||||
});
|
||||
const { unresolved_calls: _omitted, ...withoutUnresolved } = original;
|
||||
expect(
|
||||
page("unresolved_calls", {
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ const nativeViewSchema = z.strictObject({
|
|||
"unresolved_calls",
|
||||
"referenced_strings",
|
||||
"referenced_names",
|
||||
"swift_string_literals",
|
||||
"swift_string_literal_omissions",
|
||||
"native_api",
|
||||
"value_flow_summary",
|
||||
"value_flow_operations",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ const nativeRows = (
|
|||
unresolved_calls: dossier.unresolved_calls,
|
||||
referenced_strings: dossier.referenced_strings,
|
||||
referenced_names: dossier.referenced_names,
|
||||
swift_string_literals: dossier.swift_string_literals,
|
||||
swift_string_literal_omissions: dossier.swift_string_literal_omissions,
|
||||
value_flow_operations: flow?.operations,
|
||||
value_flow_def_use: flow?.def_use,
|
||||
value_flow_effects: flow?.effects,
|
||||
|
|
@ -163,7 +165,8 @@ export const projectNativeFunctionView = (
|
|||
singleton({
|
||||
available: false,
|
||||
// Only value-flow facets inherit the value-flow failure reason; an
|
||||
// older dossier simply has no unresolved_calls record.
|
||||
// older dossier simply has no unresolved_calls record, and a target
|
||||
// whose provider does not decode Swift literals has no such record.
|
||||
reason:
|
||||
view.facet.startsWith("value_flow_") && flow?.available === false
|
||||
? flow.reason
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue