diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 556bc8c..dfe3e20 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -21,9 +21,12 @@ jobs: - run: native/build-release/Release/cfb27_startup_smoke.exe native/build-release/Release/cfb27_lua_host.dll - run: native/build-release/Release/cfb27_memory_reader_smoke.exe - run: native/build-release/Release/cfb27_telemetry_smoke.exe + - run: native/build-release/Release/cfb27_memory_transaction_smoke.exe - run: native/build-release/Release/cfb27_protocol_smoke.exe native/build-release/Release/cfb27_lua_host.dll + env: + CFB27_SMOKE_ALLOW_WRITES: '1' - run: npm run pack:preview - uses: actions/upload-artifact@v4 with: - name: cfb27-lua-hook-0.2.0-dev.1 + name: cfb27-lua-hook-0.2.0-dev.2 path: dist/ diff --git a/README.md b/README.md index cb0c9e5..844bea5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Offline Lua scripting runtime, Node SDK, and MMC startup tooling for EA SPORTS College Football 27 on PC. -> Developer preview `0.2.0-dev.1`. The runtime supports one verified game +> Developer preview `0.2.0-dev.2`. The runtime supports one verified game > build, is intended only for offline play, and does not include or provide an > anticheat bypass. @@ -35,7 +35,7 @@ cfb27lua logs [--follow] cfb27lua doctor ``` -These commands are implemented in the `0.2.0-dev.1` developer preview. One-shot +These commands are implemented in the `0.2.0-dev.2` developer preview. One-shot `--json` output is a single object; followed logs use JSON Lines. ## Start here diff --git a/docs/cli.md b/docs/cli.md index af74c38..e26f4f5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,9 +18,10 @@ node packages/cli/bin/cfb27lua.cjs --help - `events [--after N]` — read a cursor page. - `memory scan` — scan bounded private readable memory through the validated SDK. - `memory read` — read one or more bounded canonical address ranges through the validated SDK. +- `memory transact ` — apply one guarded transaction from a JSON request file. - `telemetry register ` — register structured telemetry type names for the host session. -The memory commands are read-only developer diagnostics. A scan requires +The memory scan and read commands are read-only developer diagnostics. A scan requires `--pattern`, `--mask`, `--max-matches`, and `--context`; context is applied on each side of a match. The CLI automatically follows native continuation pages, uses a ten-second timeout for each scan page, and accepts `--max-pages` from 1 @@ -30,6 +31,14 @@ ranges. A read accepts one or more example, `0x7FF612340000`, not a lowercase or zero-padded form). Write-like options are not accepted. +A guarded transaction accepts exactly one `.json` file containing the SDK request +object with `transactionId` and `operations`; it does not accept stdin. The CLI resolves +the current directory and existing input through the filesystem before containment +checks, so links cannot escape the current directory. An outside target requires the +explicit `--allow-external-file` flag. `--json` and `--allow-external-file` are the only +transaction controls; scan, read, follow, cursor, and directory controls are rejected. +The SDK validates and clones the request before opening a host connection. + Use `--allow-unsupported-build` only when intentionally running a memory diagnostic against an unsupported build. Without that explicit flag, the SDK rejects an unsupported-build result. The flag does not enable writes. @@ -46,13 +55,21 @@ node packages/cli/bin/cfb27lua.cjs memory scan ` node packages/cli/bin/cfb27lua.cjs memory read ` --range 0x7FF612340000:192 --allow-unsupported-build --json +node packages/cli/bin/cfb27lua.cjs memory transact ` + proof-transaction.json --json + node packages/cli/bin/cfb27lua.cjs telemetry register ` recruiting.snapshot recruiting.stability --json ``` Human memory output reports bounded counts and the canonical addresses returned -by the SDK. JSON output keeps the validated SDK result unchanged under the -standard `{ "ok": true, "command": "...", "result": ... }` CLI envelope. +by the SDK. Scan and read JSON output keep the validated SDK result unchanged under +the standard `{ "ok": true, "command": "...", "result": ... }` CLI envelope. +Transaction `--json` output is the validated SDK result object itself, without an +envelope. +Human transaction output is narrower: it reports only the transaction ID, status, +and operation/applied/verified counts, never addresses or byte values. Transaction +errors use constant code-derived messages and omit host details in both output modes. Only `memory scan` selects the ten-second per-page client timeout; all other commands retain the SDK default timeout. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index eef9a7d..20bb079 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -1,31 +1,73 @@ # Developer-preview release checklist -- [ ] Complete the read-only memory and telemetry automated gate before changing - the preview version. -- [ ] Run `npm ci`, `npm run check`, and `npm test`. -- [ ] Configure and build all native targets with Windows x64 MSVC. -- [ ] Run startup, memory-reader, telemetry, and framed-protocol smoke - executables from the full Release build. -- [ ] Confirm CLI memory scans automatically follow continuation pages with a +This is the release ledger for `0.2.0-dev.2`, not a reusable blank template. +Checked items were completed for this release; unchecked items still require +external GitHub or post-merge work. For a later preview, copy the sequence and +reset every checkbox. + +## Reviewed candidate gate + +- [x] Complete independent task-level specification and quality reviews, then a + different-reviewer whole-branch review, before candidate installation. +- [x] Run `npm ci`, `npm run check`, and `npm test`. +- [x] Configure and build all native targets with Windows x64 MSVC. +- [x] Run startup, memory-reader, telemetry, memory-transaction, and + framed-protocol smoke executables from the full Release build. +- [x] Confirm CLI memory scans automatically follow continuation pages with a bounded `--max-pages` value and retain the scan-only timeout. -- [ ] Set `CFB27_NATIVE_ARTIFACTS` to the absolute path of that exact Release +- [x] Set `CFB27_NATIVE_ARTIFACTS` to the absolute path of that exact Release directory, then run `npm run pack:preview`. -- [ ] Run `git diff --check`. -- [ ] Confirm the staged package and both npm tarballs contain no archive, +- [x] Run `git diff --check`. +- [x] Confirm the staged package and both npm tarballs contain no archive, game/save data, schema, logs, dependencies, or build intermediates. -- [ ] Verify the external `dist/SHA256SUMS.txt` against the preview zip. The ZIP +- [x] Verify the external `dist/SHA256SUMS.txt` against the preview ZIP. The ZIP checksum cannot be embedded in documentation inside that same ZIP. -- [ ] Confirm Windows CI is green. -- [ ] With the game closed, install the exact automated-gate candidate host and - relaunch MMC offline so no previous DLL remains loaded. -- [ ] Perform the documented offline read-only runtime checklist: confirm hello - capabilities, bounded sentinel scan/read, advancing registered telemetry, - ten minutes of responsiveness, and a Dynasty hub transition. Do not use or - attempt a memory write. -- [ ] Record the date, executable hash, exact commands, and observed results in - `docs/research/runtime-verification.md` only after observing them. -- [ ] After the manual gate succeeds, set root, SDK, CLI, lockfile, release - packager, and native hello versions to `0.2.0-dev.1`, then repeat every - automated build, test, smoke, package-inspection, and diff-check step. -- [ ] Close the game and verify uninstall restores both known MMC hashes. -- [ ] Publish GitHub artifacts only; npm publication is not part of this preview. + +## Reversible offline live gate + +- [x] With both applications closed, verify the original active proxies, install + the exact reviewed candidate through the supported SDK or CLI, and + independently verify the installed proxy and host hashes. +- [x] Relaunch MMC and CFB27 offline to the Dynasty hub. Confirm the supported + executable, PID, session, capabilities, write eligibility, and exact + selected-save recipes before opting into any read-only scan. +- [x] Calibrate authority with bounded scans, stable batch rereads, allocation + topology, and a hub-to-Recruiting-to-hub lifecycle transition. Proceed + only when exactly one authoritative permission record remains; reject + presentation copies, stale neighborhoods, and unresolved replicas. +- [x] Immediately revalidate the complete record, change only the byte containing + the two-bit enum through one guarded transaction, verify the complete + alternate record and responsiveness, then restore through a second guarded + transaction and verify the complete original record. Require no lockdown + and continued write eligibility. Do not advance or write recruiting data. +- [x] Explicitly close both applications and confirm both processes are absent. +- [x] Use the supported uninstall and independently verify both original active + proxy hashes. Keep both applications closed for release preparation. +- [x] Record the date, executable hash, observed commands and results in + `docs/research/runtime-verification.md`. Retain hashes, counts, and topology + relationships, never addresses or raw bytes. + +## Final `0.2.0-dev.2` preparation + +- [x] Only after live cleanup, set root, SDK, CLI, lockfile, release packager, + SDK dependency, native hello, public documentation, CI artifact, and + policy-test versions to `0.2.0-dev.2`. +- [x] Repeat `npm ci`, syntax checks, the full Node suite, a clean Windows x64 + Release build, every native smoke, package preview and inspection, + internal and external checksum verification, and `git diff --check`. +- [x] Confirm the final bumped host was automated- and smoke-tested only. It was + not installed or exercised in the live session; live evidence applies to + the separately hashed reviewed recovery candidate. + +## External publication and immutable verification + +- [ ] Push the branch and open a draft PR against `main`; confirm Windows CI is + green, including the memory-transaction smoke and `0.2.0-dev.2` artifact. +- [ ] Complete PR review and merge into `main`. +- [ ] Tag the exact merged commit as `v0.2.0-dev.2`. +- [ ] Publish a GitHub prerelease with the immutable preview ZIP and external + checksum file. npm publication is not part of this preview. +- [ ] Download both published assets afresh and independently recompute the ZIP + SHA-256; require an exact match to the downloaded checksum file. +- [ ] Only after the immutable download verification passes, begin the Brooks + integration gate. diff --git a/docs/getting-started.md b/docs/getting-started.md index da3a57e..df1dd75 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting started -CFB27 Lua Hook `0.2.0-dev.1` is a Windows x64 developer preview. It requires +CFB27 Lua Hook `0.2.0-dev.2` is a Windows x64 developer preview. It requires Node.js 20 or later, CMake 3.24 or later, Visual Studio 2022 C++ build tools, MMC, and a separately launched offline CFB27 session. @@ -70,6 +70,7 @@ const scan = await client.scanMemory({ contextBefore: 4, contextAfter: 4, maxPages: 4096, + includeAllocationMetadata: true, }); const read = await client.readMemory({ @@ -89,6 +90,26 @@ A batch read contains at most 64 ranges of 64 KiB each and at most 256 KiB total. Unsupported game builds require `allowUnsupportedBuild: true` and report `supportedBuild: false`. +Allocation metadata is opt-in. The SDK first requires the host's +`memoryScanAllocationMetadata` capability, then returns `allocationBase`, +`allocationSize`, `allocationProtect`, and `offsetInAllocation` on every match. +Without the option, or with `includeAllocationMetadata: false`, the legacy +six-property match shape is unchanged. The equivalent CLI diagnostic is: + +```powershell +node packages/cli/bin/cfb27lua.cjs memory scan ` + --pattern CFB27A1100A1B2C3D4E5F60718293A4B ` + --mask FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ` + --max-matches 2 --context 4 ` + --include-allocation-metadata --json +``` + +The CLI preserves the SDK-validated extended JSON only for that invocation; it +does not persist it. Treat allocation addresses and topology as session-only: +discard them after a PID, host session, allocation, or validation change. +Allocation size and address order do not establish which copy is authoritative. +Use independently validated record content and lifecycle behavior instead. + All memory methods validate requests before opening the pipe and every host response field before returning it. A multi-page scan observes a live, non-atomic memory map, so re-read and validate every selected candidate before diff --git a/docs/protocol.md b/docs/protocol.md index 89046ec..854a5c3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -40,14 +40,20 @@ Error response: - `registerTelemetry { types }` — add trusted structured event names for the current host session. - `scanMemory { patternHex, maskHex, maxMatches, contextBefore, - contextAfter, allowUnsupportedBuild?, cursor? }` — scan one bounded page of - readable private memory and optionally resume from a continuation cursor. + contextAfter, allowUnsupportedBuild?, cursor?, includeAllocationMetadata? }` + — scan one bounded page of readable private memory and optionally resume from + a continuation cursor. - `readMemory { ranges, allowUnsupportedBuild? }` — read a bounded batch of readable private-memory ranges. +- `writeTransaction { transactionId, operations }` — apply a bounded guarded + batch with complete preflight comparison, readback, and rollback. `hello.capabilities` advertises the memory commands as `memoryScan` and -`memoryRead`, and structured event registration as `telemetry`. They are -read-only host operations and do not expose a write API. +`memoryRead`, allocation-aware scans as `memoryScanAllocationMetadata`, guarded +writes as `memoryWriteTransaction`, and structured event registration as +`telemetry`. `status.sessionWritesDisabled` reports whether an +unverifiable rollback has permanently disabled writes for the current host +session. ### Structured telemetry @@ -104,6 +110,24 @@ Result: {"supportedBuild":false,"complete":false,"nextCursor":"0x7FF614340000","scannedBytes":33554432,"matches":[{"address":"0x7FF612340080","regionBase":"0x7FF612340000","regionSize":65536,"protection":4,"contextAddress":"0x7FF61234007C","contextHex":"00000000CFB27A1100A1B2C3D4E5F60718293A4B00000000"}]} ``` +When `includeAllocationMetadata` is absent or the JSON boolean `false`, each +match has exactly the six legacy properties shown above. When it is `true`, the +host adds exactly four properties to every match: + +```json +{"address":"0x7FF612340080","regionBase":"0x7FF612340000","regionSize":4096,"protection":4,"contextAddress":"0x7FF61234007C","contextHex":"00000000CFB27A1100A1B2C3D4E5F60718293A4B00000000","allocationBase":"0x7FF612300000","allocationSize":4194304,"allocationProtect":4,"offsetInAllocation":262272} +``` + +`allocationBase` is the allocation identity reported by the operating system. +`allocationSize` is the checked contiguous extent of adjacent virtual-memory +regions that retain that identity. `allocationProtect` is the allocation's +initial protection, while `protection` remains the current protection of the +matched region. `offsetInAllocation` is the checked byte difference from the +allocation base, and therefore +`BigInt(address) === BigInt(allocationBase) + BigInt(offsetInAllocation)`. +Failure to discover a complete consistent extent fails the whole page with +`MEMORY_ACCESS_DENIED`; partial matches are not returned. + Every successful page contains exactly `complete`, `nextCursor`, `scannedBytes`, `matches`, and `supportedBuild`. A partial page has `complete:false` and a canonical string `nextCursor`; a terminal page has @@ -120,6 +144,15 @@ cursors. The default bounds total eligible-byte work to 128 GiB. Before using a candidate for interpretation, re-read it and validate its expected structure; the live memory map can change between pages. +Before an opt-in request, the SDK negotiates `hello` and requires +`memoryScanAllocationMetadata`; older hosts fail closed with +`PROTOCOL_MISMATCH`. Allocation addresses and topology are opaque, session-only +observations. They must not be persisted or reused after a PID, host session, +allocation lifecycle, or validation change. Allocation size and address order +are never authority signals: use independently validated content and lifecycle +behavior to distinguish authoritative state from replicas, caches, or stale +allocations. + ### Memory read `ranges` contains 1–64 objects with exactly `address` and `length` keys. @@ -145,6 +178,38 @@ keys. On an unsupported executable, `allowUnsupportedBuild` must be the JSON boolean `true` or the command returns `UNSUPPORTED_BUILD`. Successful diagnostic requests then return `supportedBuild:false`. This override never enables writes. +### Memory write transactions + +`transactionId` is 1–64 ASCII letters, digits, dots, underscores, or hyphens. +`operations` contains 1–32 objects with exactly `address`, `expectedHex`, and +`replacementHex`. Addresses use the same canonical uppercase format as memory +reads. Hex strings are nonempty uppercase byte sequences of equal length. One +operation is limited to 4,096 bytes and the request is limited to 65,536 bytes. + +```json +{"protocol":1,"id":"write-1","command":"writeTransaction","params":{"transactionId":"recruiting.proof-1","operations":[{"address":"0x7FF612340080","expectedHex":"1020","replacementHex":"1121"}]}} +``` + +A successful result records the verified outcome for every operation: + +```json +{"transactionId":"recruiting.proof-1","status":"applied_verified","operations":[{"index":0,"applied":true,"verified":true}]} +``` + +The host validates every range and compares every expected byte before the +first write. It then applies and verifies operations in request order. If an +apply or readback step fails, it restores attempted operations in reverse order +and verifies the originals. A verified rollback is returned as +`TRANSACTION_APPLY_FAILED`, with `rolled_back_verified` transaction details. An +unverifiable rollback returns `ROLLBACK_VERIFICATION_FAILED`, with +`rollback_unverified` details, and permanently rejects subsequent transaction +and Lua writes with `SESSION_WRITES_DISABLED` until the host restarts. + +This is request-level host sequencing, not game-thread atomicity: the game may +mutate memory while preflight, apply, verification, or rollback is running. +Callers must establish a stable window appropriate to the target data before +submitting a transaction. + The host retains at most 512 log entries and 1,024 events. Event cursors are monotonic for one host session. Tick events are coalesced to at most one per second; Lua tick callbacks still run at their normal cadence. @@ -160,6 +225,11 @@ Memory commands additionally return `MEMORY_ACCESS_DENIED` when a requested range is not wholly readable private memory, `SCAN_LIMIT_EXCEEDED` when the aggregate scan bound would be crossed, and `TOO_MANY_MATCHES` rather than silently truncating a scan. These errors do not include memory or region dumps. +Guarded writes additionally return `MEMORY_MISMATCH`, +`TRANSACTION_LIMIT_EXCEEDED`, `TRANSACTION_APPLY_FAILED`, +`ROLLBACK_VERIFICATION_FAILED`, and `SESSION_WRITES_DISABLED`. Malformed +transaction shapes, addresses, hex, and overlapping operations return +`INVALID_REQUEST`. The unversioned legacy text pipe remains temporarily available for migration, but it is not the integration contract for new tools. diff --git a/docs/research/runtime-verification.md b/docs/research/runtime-verification.md index 0952737..1890fc7 100644 --- a/docs/research/runtime-verification.md +++ b/docs/research/runtime-verification.md @@ -130,3 +130,54 @@ The final host above was automated- and smoke-tested after the version bump and final contract correction, but it was not manually live-tested in CFB27. The manual evidence in this document applies to host `1420F4BCAA089153E671FD41D7B89F3162EFF8AAD94B4D1EFD18039E6590D3CE`. + +## Guarded permission transaction verified on July 12, 2026 + +- The reviewed recovery candidate host SHA-256 was + `D3111AF463E543D3108055A200AFE95B7015EF4ED702F7C6AA8429BA7CEA86BD`. + The offline session used PID `25500`; hello, status, capability, and write + eligibility checks were healthy before calibration. +- Two complete read-only hub rounds scanned approximately 10.688 GB per + save-derived record with allocation metadata. Stable exact full-record + counts were zero for `LeagueSetting`, `FranchiseUser`, and Player row 2070, + and no scanner-owned self-match appeared. +- After the required hub-to-Recruiting-to-hub transition, one 75.985-second + masked `LeagueSetting` pass returned exactly one candidate. Its reconstructed + 256-byte record matched the independent selected-save recipe, remained stable + across three rereads, and its allocation and neighborhood lifecycle evidence + classified it as the unique authoritative permission record. No address or + raw byte was retained in this evidence. +- Exactly two SDK `writeTransaction` requests were sent. Each contained one + operation for one byte and was not retried. The apply request SHA-256 was + `9237D609A5F79A5F90E59077A0A7AB98EF23EE1C5E1CD14C6B7A12E85E914412`; + it returned applied and verified, and the complete `COMMISHONLY` record image + matched the selected-save recipe. +- The immediate restore request SHA-256 was + `3D622B240C7B01A8893724274BC4751F495316419FECBBED9C6900E2CA438DE5`; + it returned applied and verified, and the complete original `ANY` record + image matched. The host stayed ready, supported, and write-eligible with no + lockdown while ticks advanced from `14741` to `14744`. +- After both applications were closed and process absence was confirmed, the + supported uninstall restored both original active proxies. Each independently + verified as SHA-256 + `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`. + Neither application was relaunched for release preparation. + +### Final guarded developer-preview artifacts + +After the live gate and cleanup, the `0.2.0-dev.2` version bump passed the full +Node suite, clean Windows x64 Release build, startup, memory-reader, telemetry, +memory-transaction, and framed-protocol smokes, package preview, checksum +verification, archive inspection, and diff check. The retained artifact +SHA-256 values are: + +- Forwarding proxy: `09D38B111F6C84B196B0E960CA342860855FD3F92F5790969D6AE6E887FF15F1` +- Final host: `66203B75D53A698D7A6D6622D194AF97321C2524B05375ECBFF5DDDB0082D3AE` +- CLI tarball: `7DDF62F3774BDF3156EB274DC70E1A06031090C4DAD4376780093E4D7CA45B24` +- SDK tarball: `CDDA182B76DD9A0B4CEC40D23119A1F88095DD4D27E5DF2D152CEF61C7FBCAC6` + +The final host above was automated- and smoke-tested after the version bump, +but was not installed or manually live-tested. The reversible live evidence +applies to the reviewed recovery candidate host recorded at the start of this +section. The final ZIP checksum remains external because this document is +included in the ZIP. diff --git a/docs/safety.md b/docs/safety.md index 50d0c66..8748e30 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -23,5 +23,19 @@ provide, distribute, or document an anticheat bypass. 5. Readback equals the requested replacement byte. An unsupported build may load the host for diagnostics, but writes stay -disabled. Integrations should call `hello`, inspect `writesAllowed`, and retain -their own higher-level transaction and rollback checks. +disabled. The native `writeTransaction` command preserves the exact-build and +anticheat gates, validates and compares every operation before writing, applies +and verifies in request order, and rolls attempted operations back in reverse +order after an apply or verification failure. + +Transaction sequencing is not game-thread atomicity. The host does not suspend +the game or provide a stable snapshot; callers must establish a stable window +for the targeted data. If rollback verification fails, the host permanently +sets `sessionWritesDisabled`, and both `writeTransaction` and `cfb.write_u8` +reject all further writes until the process restarts. Integrations should call +`hello`, inspect `writesAllowed`, check `status.sessionWritesDisabled`, and +handle the transaction's verified status explicitly. + +`CFB27_SMOKE_ALLOW_WRITES=1` is a native test gate recognized only when the +hosting executable is exactly `cfb27_protocol_smoke.exe`. It does not enable +writes in the game, MMC, or any other executable. diff --git a/docs/superpowers/plans/2026-07-12-scan-authority-recovery.md b/docs/superpowers/plans/2026-07-12-scan-authority-recovery.md new file mode 100644 index 0000000..2fc79e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-scan-authority-recovery.md @@ -0,0 +1,251 @@ +# Scan Authority Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate scanner-owned false matches, expose bounded allocation topology for read-only authority classification, and complete the guarded reversible live-write proof before publishing `v0.2.0-dev.2`. + +**Architecture:** All scanner-owned binary storage moves to pagefile-backed `MEM_MAPPED` views outside the private-only scan domain. Allocation topology is an opt-in, capability-gated `scanMemory` extension; legacy protocol-v1 consumers retain their exact response shape. The live gate classifies authority through stable full-record neighborhoods and allocation lifecycle across a menu transition, never through address order, allocation size alone, the obsolete 40 MiB rule, or blind multi-copy writes. + +**Tech Stack:** C++20, Win32 virtual memory/file mappings, nlohmann JSON, Node.js CommonJS SDK/CLI, TAP, CMake/MSBuild, PowerShell. + +## Global Constraints + +- CFB27 and MMC remain closed during implementation and automated verification. +- Explicitly notify the user before candidate installation, before relaunch, and before every later close or relaunch. +- Preserve the unstaged user modification to `docs/superpowers/plans/2026-07-11-guarded-memory-transactions.md`. +- Strict TDD: every production behavior change starts with a focused test observed failing for the expected reason. +- Use one fresh implementer per task and a different reviewer for specification compliance and code quality. +- Pattern, mask, staging, and retained-context bytes must never occupy scan-eligible `MEM_PRIVATE` storage. +- Do not broaden scanning beyond committed readable non-guarded `MEM_PRIVATE` regions. +- Allocation metadata is opt-in and capability-gated; absent/false retains the exact current response. +- Addresses and raw bytes remain opaque session-only values and are never persisted in evidence. +- Never select candidates by address order, allocation size alone, or the historical 40 MiB allocation. +- Never write all plausible duplicates. No transaction is sent without one read-only-classified authoritative permission candidate. +- Do not bump, push, open a PR, tag, or publish `v0.2.0-dev.2` until live apply and restore pass. + +--- + +### Task 1: Move scanner-owned binary buffers outside `MEM_PRIVATE` + +**Files:** +- Modify: `native/host/memory_reader.h` +- Modify: `native/host/memory_reader.cpp` +- Modify: `native/host/lua_host.cpp` +- Modify: `native/smoke/memory_reader_smoke.cpp` +- Modify: `native/smoke/protocol_smoke.cpp` + +**Interfaces:** +- Produces: move-only `cfb27::memory::MappedBytes` with `Allocate`, `FromUpperHex`, `CopyFrom`, `data`, `size`, `empty`, `bytes`, and `mutable_bytes`. +- Produces: `ScanRequest.pattern`, `ScanRequest.mask`, scan staging, and `ScanMatch.context` backed by `MappedBytes`. +- Preserves: scan limits, cursors, errors, and the current wire response. + +- [ ] **Step 1: Add deterministic failing native tests** + +Add `RequireMappedStorage(pointer, message)`, using `VirtualQuery` to require committed readable `MEM_MAPPED` storage. Assert decoded pattern/mask, the `ScanReadFunction` destination, and produced match context all satisfy it. + +Add the behavioral regression: put a unique sentinel in a controlled private allocation, scan it, retain the first result, erase/free the target, then scan starting at the retained context address. Require no second match at that address. Do not teach the test an exclusion list. + +- [ ] **Step 2: Run RED** + +```powershell +cmake --build native/build-release --config Release --target cfb27_memory_reader_smoke +native\build-release\Release\cfb27_memory_reader_smoke.exe +``` + +Expected: runtime failure because current scanner-owned storage is `MEM_PRIVATE` or the retained prior context is returned. A compile failure solely for an undeclared new type is not acceptable RED evidence. + +- [ ] **Step 3: Implement mapped storage and direct decode** + +Implement this exact public shape: + +```cpp +class MappedBytes { + public: + MappedBytes() = default; + ~MappedBytes(); + MappedBytes(MappedBytes&&) noexcept; + MappedBytes& operator=(MappedBytes&&) noexcept; + MappedBytes(const MappedBytes&) = delete; + MappedBytes& operator=(const MappedBytes&) = delete; + static std::optional Allocate(std::size_t size); + static std::optional FromUpperHex(std::string_view text); + static std::optional CopyFrom(std::span bytes); + const std::uint8_t* data() const; + std::uint8_t* data(); + std::size_t size() const; + bool empty() const; + std::span bytes() const; + std::span mutable_bytes(); + private: + HANDLE mapping_{}; + std::uint8_t* view_{}; + std::size_t size_{}; +}; +``` + +Allocate with `CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, ...)` and `MapViewOfFile`; verify `MEM_MAPPED`; fail closed and clean up on every error. Destruction securely wipes, unmaps, and closes. Decode uppercase hex directly into the view without a binary vector. Use mapped storage for pattern, mask, staging, and every bounded context. Retain current pointer exclusions only as defense in depth. + +- [ ] **Step 4: Run GREEN and regressions** + +```powershell +cmake --build native/build-release --config Release --target cfb27_memory_reader_smoke cfb27_protocol_smoke cfb27_lua_host +native\build-release\Release\cfb27_memory_reader_smoke.exe +$env:CFB27_SMOKE_ALLOW_WRITES='1' +try { native\build-release\Release\cfb27_protocol_smoke.exe native\build-release\Release\cfb27_lua_host.dll } finally { Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES -ErrorAction SilentlyContinue } +npm test +``` + +Expected: mapped-storage and retained-context tests pass, protocol smoke passes, Node failures are zero, and the environment gate is unset afterward. + +- [ ] **Step 5: Self-review, report, and commit** + +Verify no scanner-owned binary vector remains, run `git diff --check`, record RED/GREEN evidence in `.superpowers/sdd/scan-recovery-task-1-report.md`, and commit only Task 1 files: + +```powershell +git commit -m "Prevent memory scan self matches" +``` + +--- + +### Task 2: Add opt-in allocation topology through native, protocol, SDK, and CLI + +**Files:** +- Modify: `native/host/memory_reader.h` +- Modify: `native/host/memory_reader.cpp` +- Modify: `native/host/lua_host.cpp` +- Modify: `native/smoke/memory_reader_smoke.cpp` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `packages/sdk/src/client.cjs` +- Modify: `packages/sdk/test/client.test.cjs` +- Modify: `packages/cli/test/main.test.cjs` +- Modify: `docs/protocol.md` +- Modify: `docs/getting-started.md` + +**Interfaces:** +- Consumes: Task 1 `MappedBytes` scan storage. +- Produces: request boolean `includeAllocationMetadata` and capability `memoryScanAllocationMetadata`. +- Produces when opted in: exact `allocationBase`, `allocationSize`, `allocationProtect`, and `offsetInAllocation` match properties. +- Preserves when not opted in: current exact SDK and CLI response. + +- [ ] **Step 1: Add native/protocol RED tests** + +Reserve three pages and commit them separately so multiple `VirtualQuery` regions share one `AllocationBase`. Put a sentinel in the middle region and opt in. Require exact base, complete three-page extent, initial protection, and checked offset. Inject a query failure at the extent boundary and require `MEMORY_ACCESS_DENIED` with no partial matches. + +Require protocol hello to advertise `memoryScanAllocationMetadata`. Require extended keys only for `includeAllocationMetadata:true`; absent/false must retain legacy exact keys. + +- [ ] **Step 2: Run native/protocol RED** + +Run the focused build and both native smokes. Expected failure is missing capability/metadata or incomplete extent discovery, never malformed test setup. + +- [ ] **Step 3: Implement checked allocation topology** + +```cpp +struct AllocationMetadata { + std::string base; + std::size_t size{}; + DWORD protection{}; + std::size_t offset{}; +}; +``` + +Add `std::optional` to `ScanMatch`. When opted in, walk adjacent `VirtualQuery` entries from `AllocationBase` while they retain that allocation base. Check overflow and forward progress, cap at the system maximum, and cache one extent per allocation base per page. Query failure or inconsistent offset returns `MEMORY_ACCESS_DENIED` and discards results. Accept only the exact new request key, conditionally emit the four fields, and advertise the capability. + +- [ ] **Step 4: Add SDK/CLI RED tests** + +Require exact boolean cloning and a capability preflight. Require strict extended keys and hostile cases for missing/extra properties, noncanonical base, unsafe sizes, offset outside allocation, and failure of: + +```js +BigInt(match.address) === BigInt(match.allocationBase) + BigInt(match.offsetInAllocation) +``` + +Require legacy tests to keep exact old requests/results. Require CLI JSON to preserve the validated extended result without persisting it. + +- [ ] **Step 5: Run SDK/CLI RED** + +```powershell +node --test packages/sdk/test/client.test.cjs +node --test packages/cli/test/main.test.cjs +``` + +Expected: opt-in behavior fails for the missing contract while legacy tests remain green. + +- [ ] **Step 6: Implement SDK/CLI and documentation** + +Clone the boolean exactly. Before an opt-in scan, negotiate hello and require `memoryScanAllocationMetadata`; fail closed with a stable public protocol/capability error if absent. Select legacy or extended exact match keys from the option and validate arithmetic with `BigInt`, never `Number` addresses. + +Document the option, capability, both shapes, lifecycle semantics, session-only rule, and prohibition against using allocation size/order as authority. + +- [ ] **Step 7: Run GREEN and full regressions** + +```powershell +npm run check +npm test +cmake --build native/build-release --config Release +native\build-release\Release\cfb27_memory_reader_smoke.exe +native\build-release\Release\cfb27_telemetry_smoke.exe +native\build-release\Release\cfb27_memory_transaction_smoke.exe +$env:CFB27_SMOKE_ALLOW_WRITES='1' +try { native\build-release\Release\cfb27_protocol_smoke.exe native\build-release\Release\cfb27_lua_host.dll } finally { Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES -ErrorAction SilentlyContinue } +git diff --check +``` + +Expected: all tests/builds/smokes pass, the gate variable is unset, and the protected plan is unstaged. + +- [ ] **Step 8: Self-review, report, and commit** + +Record evidence in `.superpowers/sdd/scan-recovery-task-2-report.md` and commit only Task 2 files: + +```powershell +git commit -m "Expose bounded scan allocation metadata" +``` + +--- + +### Task 3: Review, classify live authority, prove apply/restore, and release + +**Files:** +- Modify only after live success: `docs/research/runtime-verification.md` +- Modify only after live success: `docs/development/release-checklist.md` +- Modify only after live success: `package.json` +- Modify only after live success: `package-lock.json` +- Modify only after live success: `packages/sdk/package.json` +- Modify only after live success: `packages/cli/package.json` +- Modify only after live success: `native/host/lua_host.cpp` + +**Interfaces:** +- Consumes: reviewed Tasks 1–2 candidate and allocation metadata. +- Produces: sanitized evidence, `0.2.0-dev.2`, draft PR, immutable prerelease, verified downloads. + +- [ ] **Step 1: Complete independent review gates** + +Generate per-task review packages. Fresh reviewers must approve both specification compliance and code quality; fix and re-review every Critical/Important finding. Then obtain a different merge-base-to-head whole-branch approval. Do not install before approval. + +- [ ] **Step 2: Run the complete candidate gate** + +Run `npm ci`, check, all tests, clean x64 Release configure/build, every native smoke, package preview, manifest/checksum verification, and diff check. Record candidate SHA-256. Confirm both apps absent and both original active proxies hash to `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`. + +- [ ] **Step 3: Explicit installation and relaunch checkpoint** + +Tell the user both apps must remain closed. Install only via supported SDK/CLI and verify installed hashes. Then explicitly tell the user to launch MMC and CFB27 offline and return to Dynasty hub. Do not request Recruiting navigation yet. + +- [ ] **Step 4: Read-only authority calibration** + +Verify supported executable/PID/session/capability/write eligibility. Parse the selected save read-only and derive exact records for `LeagueSetting[0]`, `FranchiseUser[0]`, and a distinctive Player row. Opt-in scan, batch-read records/neighborhoods, and require stable samples. Record only hashes/counts/topology relationships. + +Explicitly instruct hub-to-Recruiting-to-hub navigation. Rediscover from recipes. Exclude candidates that disappear with presentation state, retain stale neighborhoods, or fail relocation. Proceed only with exactly one authoritative permission candidate; unresolved replicas mean no write and another implementation cycle. + +- [ ] **Step 5: Perform reversible controlled write** + +Immediately revalidate the full record. Change only the byte containing the two-bit enum, preserving other bits. Verify transaction, full alternate image, responsiveness, and no lockdown. Immediately restore with a second transaction and verify the full original image, responsiveness, and writes remain enabled. Do not advance or write recruiting data. + +- [ ] **Step 6: Explicit close and restore checkpoint** + +Tell the user to close both apps. Confirm process absence, supported uninstall/restore, and both original proxy hashes. Do not relaunch for packaging. + +- [ ] **Step 7: Evidence, versions, and release verification** + +Only after live success, add sanitized evidence, set every version to `0.2.0-dev.2`, rerun the full gate, and commit `Prepare guarded write developer preview`. + +- [ ] **Step 8: Push, draft PR, merge, and immutable prerelease** + +Push the branch, open a draft PR against `main`, and complete review. After merge, tag the exact merge commit, publish immutable ZIP/checksum assets as a prerelease, download them afresh, and independently recompute SHA-256. Do not start Brooks integration before verification. diff --git a/docs/superpowers/specs/2026-07-12-scan-authority-recovery-design.md b/docs/superpowers/specs/2026-07-12-scan-authority-recovery-design.md new file mode 100644 index 0000000..6beed56 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-scan-authority-recovery-design.md @@ -0,0 +1,191 @@ +# Scan Authority Recovery Design + +**Status:** Approved for recovery implementation + +**Date:** 2026-07-12 + +## Purpose + +Repair the read-only discovery contract that prevented the guarded-memory +transaction branch from proving a reversible write in a live offline CFB27 +session. The guarded transaction engine remains unchanged unless a regression +test proves a defect in it. + +## Failure being corrected + +The live gate relied on an exact full-record scan to rediscover one previously +verified authoritative Dynasty permission record. Repeated in-process scans +materialized decoded patterns, masks, 4 MiB scan chunks, and returned match +contexts in ordinary private storage. Those stale or retained copies were +eligible targets on later pages and requests and eventually caused +`TOO_MANY_MATCHES`. An external read-only scan removed those host-created +matches but still found two plausible game-owned `FranchiseUser` copies. The +current response exposes region identity but not allocation identity, so the +gate could not relate candidates across different verified franchise tables. + +No live transaction was sent. The safety refusal was correct, but the +end-to-end writing objective was not met. + +## Considered approaches + +### Add more pointer exclusions + +Exclude the current `std::vector` buffers and any result contexts from the +scan. This is insufficient because freed heap blocks can retain earlier +decoded patterns after their owning pointers are gone. It treats observed +addresses rather than the source of the defect. + +### Move scanning to a separate process + +An external process naturally avoids in-process pattern copies and already +demonstrated clean candidate enumeration. It would, however, create a second +shipping executable, duplicate build and safety negotiation, and break the +existing startup-loaded host ownership boundary. This is disproportionate for +the prerequisite release. + +### Use non-private pattern storage and expose allocation identity + +This is the selected approach. Decode scan patterns and masks directly into +pagefile-backed mapped views and use the same non-private storage class for the +scan staging buffer and retained match contexts. `VirtualQuery` reports these +views as `MEM_MAPPED`, while the scanner accepts only `MEM_PRIVATE`, so +scanner-owned binary data cannot become a candidate on a later page or request. +Return bounded allocation identity for each real match so the live gate can +group exact records from independent tables and reject ambiguous groups +without guessing. + +## Native design + +Add a move-only RAII byte buffer backed by `CreateFileMappingW` with +`INVALID_HANDLE_VALUE` and `MapViewOfFile`. It must: + +- reject zero length and lengths above the existing 4096-byte pattern limit; +- decode uppercase hexadecimal directly into the mapped view without first + creating a binary `std::vector`; +- expose an immutable byte span to the scan engine; +- securely clear and unmap the view on destruction; and +- close every mapping handle on success and failure paths. + +`ScanRequest.pattern`, `ScanRequest.mask`, the scan staging buffer, and every +`ScanMatch.context` use this mapped storage. Mapped buffers must remain movable +without copying their contents and contexts must remain serializable as bounded +byte spans. The existing scanner remains restricted to complete committed +readable `MEM_PRIVATE` regions. It must not rely on current-buffer pointer +exclusions for correctness; those checks may remain only as defense in depth. + +For each match, return: + +- the existing address, region base, region size, protection, and bounded + context; +- `allocationBase`, taken from `MEMORY_BASIC_INFORMATION.AllocationBase`; and +- `allocationSize`, computed as the bounded contiguous virtual extent whose + regions report that same allocation base; +- `allocationProtect`, taken from the allocation's initial protection; and +- `offsetInAllocation`, computed as the checked difference between the match + address and allocation base. + +Allocation sizes must be overflow checked. Failure to establish allocation +identity fails the scan page rather than returning partial or fabricated +metadata. + +## Protocol and SDK design + +Add an opt-in `includeAllocationMetadata` boolean to `scanMemory`. When absent +or false, the existing exact response shape is unchanged. When true, every +match additionally requires exact `allocationBase`, `allocationSize`, +`allocationProtect`, and `offsetInAllocation` properties. The host advertises +the `memoryScanAllocationMetadata` capability so an updated SDK fails closed +against an older host. + +Addresses remain canonical opaque session values. The SDK strictly validates +the legacy shape when the option is false and the extended shape when it is +true, clones results, and does not persist them. The CLI continues to pass +through the validated JSON result and must not add a write option or +domain-specific classifier. + +## Evidence-based live authority rule + +The hook remains game-domain neutral. The live gate begins with independently +parsed exact full-record recipes for: + +1. `LeagueSetting[0]`; +2. `FranchiseUser[0]`; and +3. one distinctive `Player` row with multiple independently checked identity + and rating fields. + +For every permission candidate, the gate records only in-memory session state: +complete record bytes, a bounded schema-derived neighborhood, allocation +topology, and relationships to the other independently verified records. It +requires a stable candidate set across consecutive samples, then observes a +Dynasty hub to Recruiting screen to Dynasty hub transition and rediscovers from +the save-derived recipes rather than cached addresses. + +A candidate may be classified authoritative only when its validated table +neighborhood remains current or relocates with the game-owned Dynasty table +topology. A candidate that disappears with presentation state, retains stale +neighboring data, or fails relocation is excluded as cache/stale. Candidates +that remain indistinguishable are an unresolved replica set and do not permit a +write. Allocation size, address order, and the historical 40 MiB observation +are never authority signals. Every full record must be read and compared again +after the transition and immediately before a transaction. Absolute addresses +are never reused after a PID, host session, allocation, or validation change. + +If zero or multiple allocation groups satisfy the rule, the live gate remains +open and no write is sent. Writing every plausible duplicate is explicitly not +an acceptable substitute for authority classification. + +## Reversible live-write proof + +After unique authority is established in an offline supported-build session: + +1. Re-read and validate the complete selected permission record. +2. Send one guarded transaction changing only the byte containing the selected + two-bit enum field, preserving the other six bits. +3. Read and verify the complete alternate record image. +4. Immediately send a second guarded transaction restoring the original byte. +5. Read and verify the complete original record image. +6. Verify the game remains responsive and the session has not entered write + lockdown. + +No week advance, recruiting write, reference edit, autosave claim, or blind +retry belongs in this prerequisite gate. + +## TDD and review gates + +- Native RED must prove pattern, mask, staging, and retained match-context + storage all report `MEM_MAPPED` and that a second scan does not find a + retained prior-result context. +- Native RED must prove allocation base/size are exact for controlled + multi-region allocations and fail closed on invalid extent discovery. +- Protocol and SDK RED tests must preserve the legacy response without the + option, negotiate the capability, and reject missing, noncanonical, + overflowing, inconsistent, or extra allocation metadata when opted in. +- Existing pagination, response-size, scan-limit, read, transaction, rollback, + Lua, packaging, and CI gates must remain green. +- A fresh implementer performs each task through RED/GREEN TDD. +- A different subagent reviews specification compliance and code quality after + each task; Critical and Important findings block progress. +- A whole-branch reviewer gates candidate installation. + +## Release boundary + +CFB27 and MMC stay closed during implementation and automated verification. +The user receives explicit instructions before candidate installation and again +before relaunch. `0.2.0-dev.2`, a push, pull request, tag, or release is allowed +only after the reversible live-write proof passes and downloaded release assets +verify against their published SHA-256 checksums. + +## Completion criteria + +This recovery is complete only when: + +- repeated host scans no longer return host-created binary pattern copies; +- genuine matches contain strict opt-in allocation identity through native, + protocol, SDK, and CLI layers; +- one authoritative permission record is uniquely classified using stable + neighborhoods, allocation lifecycle, three independent full-record recipes, + and the transition rule; +- the alternate and restoration transactions both apply and verify in CFB27; +- the game remains responsive with writes still enabled; and +- the independently reviewed `v0.2.0-dev.2` prerelease is published immutable + and its freshly downloaded assets pass SHA-256 verification. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 8e63218..c44b2f5 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -46,6 +46,7 @@ set_target_properties(lua54 PROPERTIES POSITION_INDEPENDENT_CODE ON) add_library(cfb27_lua_host SHARED host/lua_host.cpp host/memory_reader.cpp + host/memory_transaction.cpp host/protocol.cpp host/telemetry.cpp ) @@ -63,6 +64,7 @@ target_compile_definitions(cfb27_cryptbase_proxy PRIVATE WIN32_LEAN_AND_MEAN NOM add_executable(cfb27_startup_smoke smoke/startup_host_smoke.cpp) target_compile_features(cfb27_startup_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_startup_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(cfb27_startup_smoke PRIVATE nlohmann_json::nlohmann_json) target_link_options(cfb27_startup_smoke PRIVATE /STACK:1048576) add_executable(cfb27_protocol_smoke smoke/protocol_smoke.cpp) @@ -79,6 +81,14 @@ target_compile_features(cfb27_memory_reader_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_memory_reader_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_options(cfb27_memory_reader_smoke PRIVATE /STACK:1048576) +add_executable(cfb27_memory_transaction_smoke + smoke/memory_transaction_smoke.cpp + host/memory_transaction.cpp +) +target_compile_features(cfb27_memory_transaction_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_memory_transaction_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_options(cfb27_memory_transaction_smoke PRIVATE /STACK:1048576) + add_executable(cfb27_telemetry_smoke smoke/telemetry_smoke.cpp host/telemetry.cpp diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 3bc6867..8d915ec 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -3,6 +3,7 @@ #include #include "memory_reader.h" +#include "memory_transaction.h" #include "protocol.h" #include "telemetry.h" @@ -38,7 +39,7 @@ namespace { constexpr wchar_t kPipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost."; constexpr wchar_t kV1PipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost.v1."; -constexpr char kHostVersion[] = "0.2.0-dev.1"; +constexpr char kHostVersion[] = "0.2.0-dev.2"; constexpr std::uintmax_t kSupportedExecutableSize = 247845776; constexpr char kSupportedExecutableSha256[] = "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8"; constexpr DWORD kTickMilliseconds = 100; @@ -63,9 +64,11 @@ struct LogEntry { std::atomic g_running{true}; std::atomic g_ready{false}; std::atomic g_supported_build{false}; +std::atomic g_session_writes_disabled{false}; std::atomic g_scripts_run{0}; std::atomic g_ticks{0}; std::mutex g_lua_mutex; +std::mutex g_host_write_mutex; std::mutex g_event_mutex; std::mutex g_file_log_mutex; lua_State* g_lua{}; @@ -184,6 +187,35 @@ bool SupportedBuild() { return g_supported_build.load(std::memory_order_acquire); } +bool EnvironmentIsOne(const wchar_t* name) { + wchar_t value[2]{}; + return GetEnvironmentVariableW(name, value, static_cast(std::size(value))) == 1 && + value[0] == L'1'; +} + +bool SmokeWritesAllowed() { + wchar_t executable[MAX_PATH]{}; + if (!GetModuleFileNameW(nullptr, executable, MAX_PATH)) return false; + const auto name = std::filesystem::path(executable).filename().wstring(); + if (_wcsicmp(name.c_str(), L"cfb27_protocol_smoke.exe") != 0) return false; + return EnvironmentIsOne(L"CFB27_SMOKE_ALLOW_WRITES"); +} + +bool SmokeRollbackUnverifiedRequested() { + return SmokeWritesAllowed() && + EnvironmentIsOne(L"CFB27_SMOKE_FORCE_ROLLBACK_UNVERIFIED"); +} + +bool SmokeHoldRollbackRequested() { + return SmokeRollbackUnverifiedRequested() && + EnvironmentIsOne(L"CFB27_SMOKE_HOLD_ROLLBACK"); +} + +bool SmokeApplyFailureRequested() { + return SmokeWritesAllowed() && + EnvironmentIsOne(L"CFB27_SMOKE_FORCE_APPLY_FAILURE"); +} + bool RealAnticheatIsRunning() { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) return true; @@ -217,6 +249,62 @@ bool RealAnticheatIsRunning() { return found; } +bool WriteEnvironmentAllowed() { + return (SupportedBuild() || SmokeWritesAllowed()) && !RealAnticheatIsRunning(); +} + +class SmokeRollbackUnverifiedBackend final : public cfb27::memory::MemoryBackend { + public: + explicit SmokeRollbackUnverifiedBackend(cfb27::memory::MemoryBackend& backend) + : backend_(backend) {} + + bool Validate(std::uintptr_t address, std::size_t size, bool writable) override { + return backend_.Validate(address, size, writable); + } + + bool Read(std::uintptr_t address, std::span output) override { + ++reads_; + if (reads_ >= 2) { + if (reads_ == 2 && SmokeHoldRollbackRequested()) Sleep(500); + return false; + } + return backend_.Read(address, output); + } + + bool Write(std::uintptr_t address, + std::span input) override { + return backend_.Write(address, input); + } + + private: + cfb27::memory::MemoryBackend& backend_; + std::size_t reads_{}; +}; + +class SmokeApplyFailureBackend final : public cfb27::memory::MemoryBackend { + public: + explicit SmokeApplyFailureBackend(cfb27::memory::MemoryBackend& backend) + : backend_(backend) {} + + bool Validate(std::uintptr_t address, std::size_t size, bool writable) override { + return backend_.Validate(address, size, writable); + } + + bool Read(std::uintptr_t address, std::span output) override { + return backend_.Read(address, output); + } + + bool Write(std::uintptr_t address, + std::span input) override { + if (++writes_ == 1) return false; + return backend_.Write(address, input); + } + + private: + cfb27::memory::MemoryBackend& backend_; + std::size_t writes_{}; +}; + bool IsAccessible(std::uintptr_t address, std::size_t size, bool writable) { if (!address || !size || address + size < address) return false; MEMORY_BASIC_INFORMATION info{}; @@ -246,16 +334,32 @@ int LuaWriteU8(lua_State* state) { const auto address = static_cast(luaL_checkinteger(state, 1)); const int expected = static_cast(luaL_checkinteger(state, 2)); const int value = static_cast(luaL_checkinteger(state, 3)); - if (!SupportedBuild()) return luaL_error(state, "unsupported College Football 27 build"); - if (RealAnticheatIsRunning()) return luaL_error(state, "writes are disabled while EA anticheat is running"); - if (expected < 0 || expected > 255 || value < 0 || value > 255) - return luaL_error(state, "byte values must be between 0 and 255"); - if (!IsAccessible(address, 1, true)) return luaL_error(state, "address is not writable"); - auto* target = reinterpret_cast(address); - if (*target != static_cast(expected)) - return luaL_error(state, "expected byte does not match live memory"); - *target = static_cast(value); - lua_pushboolean(state, *target == static_cast(value)); + const char* error = nullptr; + bool verified = false; + { + std::lock_guard write_lock(g_host_write_mutex); + if (g_session_writes_disabled.load(std::memory_order_acquire)) { + error = "session writes are disabled"; + } else if (!SupportedBuild() && !SmokeWritesAllowed()) { + error = "unsupported College Football 27 build"; + } else if (RealAnticheatIsRunning()) { + error = "writes are disabled while EA anticheat is running"; + } else if (expected < 0 || expected > 255 || value < 0 || value > 255) { + error = "byte values must be between 0 and 255"; + } else if (!IsAccessible(address, 1, true)) { + error = "address is not writable"; + } else { + auto* target = reinterpret_cast(address); + if (*target != static_cast(expected)) { + error = "expected byte does not match live memory"; + } else { + *target = static_cast(value); + verified = *target == static_cast(value); + } + } + } + if (error) return luaL_error(state, "%s", error); + lua_pushboolean(state, verified); return 1; } @@ -706,7 +810,7 @@ std::optional> HexToBytes( return bytes; } -std::string BytesToHex(const std::vector& bytes) { +std::string BytesToHex(std::span bytes) { constexpr char digits[] = "0123456789ABCDEF"; std::string encoded(bytes.size() * 2, '0'); for (std::size_t index = 0; index < bytes.size(); ++index) { @@ -755,6 +859,46 @@ cfb27::protocol::Json MemoryError( return ErrorResponse(id, "INVALID_REQUEST", "Invalid memory request"); } +cfb27::protocol::Json TransactionResultJson( + std::string_view transaction_id, + const cfb27::memory::TransactionResult& transaction) { + using Json = cfb27::protocol::Json; + Json operations = Json::array(); + for (const auto& operation : transaction.operations) { + operations.push_back({ + {"index", operation.index}, + {"applied", operation.applied}, + {"verified", operation.verified}, + }); + } + return { + {"transactionId", transaction_id}, + {"status", transaction.code}, + {"operations", std::move(operations)}, + }; +} + +cfb27::protocol::Json TransactionRejected( + const std::string& id, std::string_view engine_code) { + using cfb27::protocol::ErrorResponse; + if (engine_code == "expected_mismatch") { + return ErrorResponse(id, "MEMORY_MISMATCH", + "Live memory does not match the transaction preflight"); + } + if (engine_code == "invalid_memory_range" || + engine_code == "preflight_read_failed") { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", + "Transaction memory is not accessible for writing"); + } + if (engine_code == "invalid_operation_count" || + engine_code == "invalid_operation_size" || + engine_code == "transaction_too_large") { + return ErrorResponse(id, "TRANSACTION_LIMIT_EXCEEDED", + "Transaction exceeds an operation or byte limit"); + } + return ErrorResponse(id, "INVALID_REQUEST", "Invalid writeTransaction request"); +} + cfb27::protocol::Json LogsResult(std::size_t limit) { cfb27::protocol::Json logs = cfb27::protocol::Json::array(); std::lock_guard lock(g_event_mutex); @@ -792,7 +936,9 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { const std::string id = request.is_object() && request.contains("id") && request["id"].is_string() ? request["id"].get() : ""; - if (!request.is_object() || !request.contains("protocol") || + if (!request.is_object() || + !HasOnlyKeys(request, {"protocol", "id", "command", "params"}) || + !request.contains("protocol") || !request["protocol"].is_number_integer() || request["protocol"].get() != static_cast(cfb27::protocol::kVersion) || !request.contains("id") || !request["id"].is_string() || @@ -808,7 +954,9 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { } const bool supported = SupportedBuild(); - const bool writes_allowed = supported && !RealAnticheatIsRunning(); + const bool session_writes_disabled = + g_session_writes_disabled.load(std::memory_order_acquire); + const bool writes_allowed = !session_writes_disabled && WriteEnvironmentAllowed(); if (command == "hello") { return SuccessResponse(id, { {"protocolVersion", cfb27::protocol::kVersion}, @@ -816,7 +964,9 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { {"supportedBuild", supported}, {"writesAllowed", writes_allowed}, {"capabilities", {"status", "runScript", "evaluate", "logs", "events", - "memoryScan", "memoryRead", "telemetry"}}, + "memoryScan", "memoryScanAllocationMetadata", "memoryRead", + "memoryWriteTransaction", + "telemetry"}}, }); } @@ -830,12 +980,97 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { {"ready", g_ready.load(std::memory_order_acquire)}, {"supportedBuild", supported}, {"writesAllowed", writes_allowed}, + {"sessionWritesDisabled", session_writes_disabled}, {"scriptsRun", g_scripts_run.load(std::memory_order_acquire)}, {"ticks", g_ticks.load(std::memory_order_acquire)}, {"lastError", std::move(last_error)}, }); } + if (command == "writeTransaction") { + std::lock_guard write_lock(g_host_write_mutex); + if (g_session_writes_disabled.load(std::memory_order_acquire)) { + return ErrorResponse(id, "SESSION_WRITES_DISABLED", + "Writes are disabled for the remainder of this host session"); + } + if (!SupportedBuild() && !SmokeWritesAllowed()) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Memory writes require the exact supported build"); + } + if (RealAnticheatIsRunning()) { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", + "Writes are disabled while EA anticheat is running"); + } + if (!HasOnlyKeys(params, {"transactionId", "operations"}) || + !params.contains("transactionId") || !params["transactionId"].is_string() || + !params.contains("operations") || !params["operations"].is_array()) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid writeTransaction params"); + } + cfb27::memory::TransactionRequest transaction_request{ + .transaction_id = params["transactionId"].get(), + }; + transaction_request.operations.reserve(params["operations"].size()); + std::size_t aggregate_bytes = 0; + for (const auto& operation : params["operations"]) { + if (!operation.is_object() || + !HasOnlyKeys(operation, {"address", "expectedHex", "replacementHex"}) || + !operation.contains("address") || !operation["address"].is_string() || + !operation.contains("expectedHex") || + !operation.contains("replacementHex")) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid transaction operation"); + } + const auto address = + CanonicalAddress(operation["address"].get_ref()); + auto expected = HexToBytes(operation["expectedHex"]); + auto replacement = HexToBytes(operation["replacementHex"]); + if (!address || !expected || !replacement || + expected->size() != replacement->size()) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid transaction operation"); + } + if (replacement->size() > cfb27::memory::kMaxOperationBytes || + aggregate_bytes > + cfb27::memory::kMaxTransactionBytes - replacement->size()) { + return ErrorResponse(id, "TRANSACTION_LIMIT_EXCEEDED", + "Transaction exceeds the byte limit"); + } + aggregate_bytes += replacement->size(); + transaction_request.operations.push_back({ + .address = std::move(*address), + .expected = std::move(*expected), + .replacement = std::move(*replacement), + }); + } + + cfb27::memory::ProcessMemoryBackend process_backend; + cfb27::memory::TransactionResult transaction; + if (SmokeRollbackUnverifiedRequested()) { + SmokeRollbackUnverifiedBackend smoke_backend(process_backend); + transaction = cfb27::memory::RunTransaction(transaction_request, smoke_backend); + } else if (SmokeApplyFailureRequested()) { + SmokeApplyFailureBackend smoke_backend(process_backend); + transaction = cfb27::memory::RunTransaction(transaction_request, smoke_backend); + } else { + transaction = cfb27::memory::RunTransaction(transaction_request, process_backend); + } + if (transaction.status == cfb27::memory::TransactionStatus::kRejected) { + return TransactionRejected(id, transaction.code); + } + const auto result = TransactionResultJson( + transaction_request.transaction_id, transaction); + if (transaction.status == + cfb27::memory::TransactionStatus::kRollbackUnverified) { + g_session_writes_disabled.store(true, std::memory_order_release); + return ErrorResponse(id, "ROLLBACK_VERIFICATION_FAILED", + "Transaction rollback could not be verified", result); + } + if (transaction.status == + cfb27::memory::TransactionStatus::kRolledBackVerified) { + return ErrorResponse(id, "TRANSACTION_APPLY_FAILED", + "Transaction failed and was rolled back", result); + } + return SuccessResponse(id, result); + } + if (command == "registerTelemetry") { if (!HasOnlyKeys(params, {"types"}) || !params.contains("types") || !params["types"].is_array() || params["types"].empty() || @@ -863,6 +1098,11 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { return ErrorResponse(id, "INVALID_REQUEST", "allowUnsupportedBuild must be a boolean"); } + if (params.contains("includeAllocationMetadata") && + !params["includeAllocationMetadata"].is_boolean()) { + return ErrorResponse(id, "INVALID_REQUEST", + "includeAllocationMetadata must be a boolean"); + } const bool allow_unsupported = params.contains("allowUnsupportedBuild") && params["allowUnsupportedBuild"].get(); if (!supported && !allow_unsupported) { @@ -870,12 +1110,19 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { "Memory scanning requires a supported build or explicit override"); } if (!HasOnlyKeys(params, {"patternHex", "maskHex", "maxMatches", "contextBefore", - "contextAfter", "allowUnsupportedBuild", "cursor"}) || + "contextAfter", "allowUnsupportedBuild", "cursor", + "includeAllocationMetadata"}) || !params.contains("patternHex") || !params.contains("maskHex")) { return ErrorResponse(id, "INVALID_REQUEST", "Invalid scanMemory params"); } - auto pattern = HexToBytes(params["patternHex"]); - auto mask = HexToBytes(params["maskHex"]); + auto pattern = params["patternHex"].is_string() + ? cfb27::memory::DecodeScanHex( + params["patternHex"].get_ref()) + : std::nullopt; + auto mask = params["maskHex"].is_string() + ? cfb27::memory::DecodeScanHex( + params["maskHex"].get_ref()) + : std::nullopt; const auto max_matches = ReadUnsigned( params, "maxMatches", 1, cfb27::memory::kMaxMatches); const auto context_before = ReadUnsigned( @@ -907,6 +1154,8 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { .context_before = *context_before, .context_after = *context_after, .cursor = std::move(cursor), + .include_allocation_metadata = + params.value("includeAllocationMetadata", false), }); if (!scan.code.empty()) return MemoryError(id, scan.code); @@ -918,14 +1167,28 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { if (!address || !region_base || !context_address) { return ErrorResponse(id, "MEMORY_ACCESS_DENIED", "Memory scan returned an invalid address"); } - matches.push_back({ + Json response_match{ {"address", FormatCanonicalAddress(*address)}, {"regionBase", FormatCanonicalAddress(*region_base)}, {"regionSize", match.region_size}, {"protection", match.protection}, {"contextAddress", FormatCanonicalAddress(*context_address)}, - {"contextHex", BytesToHex(match.context)}, - }); + {"contextHex", BytesToHex(match.context.bytes())}, + }; + if (match.allocation) { + const auto allocation_base = + cfb27::memory::ParseAddress(match.allocation->base); + if (!allocation_base) { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", + "Memory scan returned an invalid allocation address"); + } + response_match["allocationBase"] = + FormatCanonicalAddress(*allocation_base); + response_match["allocationSize"] = match.allocation->size; + response_match["allocationProtect"] = match.allocation->protection; + response_match["offsetInAllocation"] = match.allocation->offset; + } + matches.push_back(std::move(response_match)); } return SuccessResponse(id, { {"supportedBuild", supported}, diff --git a/native/host/memory_reader.cpp b/native/host/memory_reader.cpp index dde9555..1d89508 100644 --- a/native/host/memory_reader.cpp +++ b/native/host/memory_reader.cpp @@ -4,8 +4,8 @@ #include #include #include -#include #include +#include #include namespace cfb27::memory { @@ -37,6 +37,14 @@ bool IsReadableProtection(DWORD protection) { } } +bool IsMappedReadableStorage(const void* pointer) { + MEMORY_BASIC_INFORMATION info{}; + return pointer != nullptr && + VirtualQuery(pointer, &info, sizeof(info)) == sizeof(info) && + info.State == MEM_COMMIT && info.Type == MEM_MAPPED && + IsReadableProtection(info.Protect); +} + struct ValidatedRead { std::uintptr_t address{}; std::size_t length{}; @@ -58,7 +66,8 @@ bool IsWithinOneEligibleRegion(std::uintptr_t address, std::size_t length) { bool PatternMatches(const std::uint8_t* bytes, const ScanRequest& request) { for (std::size_t i = 0; i < request.pattern.size(); ++i) { - if ((bytes[i] & request.mask[i]) != (request.pattern[i] & request.mask[i])) return false; + if ((bytes[i] & request.mask.data()[i]) != + (request.pattern.data()[i] & request.mask.data()[i])) return false; } return true; } @@ -77,7 +86,7 @@ bool OverlapsMatchContext(std::uintptr_t candidate, std::size_t candidate_length const ScanResult& result) { for (const auto& match : result.matches) { if (OverlapsRange(candidate, candidate_length, match.context.data(), - match.context.capacity())) { + match.context.size())) { return true; } } @@ -93,14 +102,164 @@ bool ProductionRead(const void* source, void* destination, std::size_t length, return ok; } -struct VirtualFreeDeleter { - void operator()(std::uint8_t* allocation) const { - if (allocation != nullptr) VirtualFree(allocation, 0, MEM_RELEASE); +} // namespace + +MappedBytes::~MappedBytes() { + if (view_ != nullptr) { + SecureZeroMemory(view_, size_); + UnmapViewOfFile(view_); } + if (mapping_ != nullptr) CloseHandle(mapping_); +} + +namespace { + +SIZE_T ProductionQuery(const void* address, MEMORY_BASIC_INFORMATION* information, + SIZE_T length) { + return VirtualQuery(address, information, length); +} + +struct AllocationExtent { + std::size_t size{}; + DWORD protection{}; }; +std::optional ResolveAllocationMetadata( + std::uintptr_t match_address, const MEMORY_BASIC_INFORMATION& match_info, + std::uintptr_t maximum, ScanQueryFunction query, + std::unordered_map& extents) { + const auto allocation_base = + reinterpret_cast(match_info.AllocationBase); + if (allocation_base == 0 || match_address < allocation_base) return std::nullopt; + + auto cached = extents.find(allocation_base); + if (cached == extents.end()) { + auto cursor = allocation_base; + std::size_t allocation_size = 0; + DWORD allocation_protection = 0; + bool first = true; + while (cursor <= maximum) { + MEMORY_BASIC_INFORMATION info{}; + if (query(reinterpret_cast(cursor), &info, sizeof(info)) != + sizeof(info)) { + return std::nullopt; + } + const auto base = reinterpret_cast(info.BaseAddress); + const auto queried_allocation = + reinterpret_cast(info.AllocationBase); + if (queried_allocation != allocation_base) break; + if (base != cursor || info.RegionSize == 0 || + AddOverflows(base, info.RegionSize) || + SizeAddOverflows(allocation_size, info.RegionSize)) { + return std::nullopt; + } + if (first) { + allocation_protection = info.AllocationProtect; + first = false; + } + allocation_size += info.RegionSize; + const auto next = base + info.RegionSize; + if (next <= cursor) return std::nullopt; + if (next > maximum) { + const auto capped_size = maximum - allocation_base + 1; + allocation_size = static_cast(capped_size); + cursor = next; + break; + } + cursor = next; + } + if (first || allocation_size == 0) return std::nullopt; + cached = extents.emplace(allocation_base, + AllocationExtent{allocation_size, + allocation_protection}).first; + } + + const auto offset_value = match_address - allocation_base; + if (offset_value > std::numeric_limits::max()) return std::nullopt; + const auto offset = static_cast(offset_value); + if (offset >= cached->second.size) return std::nullopt; + return AllocationMetadata{ + FormatAddress(allocation_base), cached->second.size, + cached->second.protection, offset}; +} + } // namespace +MappedBytes::MappedBytes(MappedBytes&& other) noexcept + : mapping_(std::exchange(other.mapping_, nullptr)), + view_(std::exchange(other.view_, nullptr)), + size_(std::exchange(other.size_, 0)) {} + +MappedBytes& MappedBytes::operator=(MappedBytes&& other) noexcept { + if (this == &other) return *this; + if (view_ != nullptr) { + SecureZeroMemory(view_, size_); + UnmapViewOfFile(view_); + } + if (mapping_ != nullptr) CloseHandle(mapping_); + mapping_ = std::exchange(other.mapping_, nullptr); + view_ = std::exchange(other.view_, nullptr); + size_ = std::exchange(other.size_, 0); + return *this; +} + +std::optional MappedBytes::Allocate(std::size_t size) { + if (size == 0) return std::nullopt; + const auto size64 = static_cast(size); + MappedBytes result; + result.mapping_ = CreateFileMappingW( + INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, + static_cast(size64 >> 32), static_cast(size64), nullptr); + if (result.mapping_ == nullptr) return std::nullopt; + result.view_ = static_cast( + MapViewOfFile(result.mapping_, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, size)); + result.size_ = size; + if (result.view_ == nullptr || !IsMappedReadableStorage(result.view_)) return std::nullopt; + return result; +} + +std::optional MappedBytes::FromUpperHex(std::string_view text) { + if (text.empty() || text.size() % 2 != 0) return std::nullopt; + auto decoded = Allocate(text.size() / 2); + if (!decoded) return std::nullopt; + auto nibble = [](char character) -> std::optional { + if (character >= '0' && character <= '9') { + return static_cast(character - '0'); + } + if (character >= 'A' && character <= 'F') { + return static_cast(character - 'A' + 10); + } + return std::nullopt; + }; + for (std::size_t index = 0; index < text.size(); index += 2) { + const auto high = nibble(text[index]); + const auto low = nibble(text[index + 1]); + if (!high || !low) return std::nullopt; + decoded->view_[index / 2] = static_cast((*high << 4) | *low); + } + return decoded; +} + +std::optional DecodeScanHex(std::string_view text) { + if (text.size() > kMaxPatternBytes * 2) return std::nullopt; + return MappedBytes::FromUpperHex(text); +} + +std::optional MappedBytes::CopyFrom( + std::span bytes) { + auto copy = Allocate(bytes.size()); + if (!copy) return std::nullopt; + std::copy(bytes.begin(), bytes.end(), copy->view_); + return copy; +} + +const std::uint8_t* MappedBytes::data() const { return view_; } +std::uint8_t* MappedBytes::data() { return view_; } +std::size_t MappedBytes::size() const { return size_; } +bool MappedBytes::empty() const { return size_ == 0; } +std::span MappedBytes::bytes() const { return {view_, size_}; } +std::span MappedBytes::mutable_bytes() { return {view_, size_}; } + std::optional ParseAddress(std::string_view text) { if (text.size() >= 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) { text.remove_prefix(2); @@ -177,7 +336,8 @@ BatchReadResult ReadMemoryBatch(const std::vector& ranges) { return result; } -ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) { +ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read, + ScanQueryFunction query) { ScanResult result; if (request.pattern.size() < kMinPatternBytes || request.pattern.size() > kMaxPatternBytes || @@ -206,22 +366,21 @@ ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) } const auto buffer_capacity = kScanChunkBytes + request.pattern.size() - 1; - std::unique_ptr scan_buffer( - static_cast(VirtualAlloc(nullptr, buffer_capacity, - MEM_RESERVE | MEM_COMMIT, - PAGE_READWRITE))); + auto scan_buffer = MappedBytes::Allocate(buffer_capacity); if (!scan_buffer) { result.code = kMemoryAccessDenied; return result; } - const auto buffer_begin = reinterpret_cast(scan_buffer.get()); + const auto buffer_begin = reinterpret_cast(scan_buffer->data()); const auto buffer_end = buffer_begin + buffer_capacity; const auto read_chunk = read != nullptr ? read : ProductionRead; + const auto query_region = query != nullptr ? query : ProductionQuery; + std::unordered_map allocation_extents; result.matches.reserve(request.max_matches + 1); while (cursor <= maximum) { MEMORY_BASIC_INFORMATION info{}; - if (VirtualQuery(reinterpret_cast(cursor), &info, sizeof(info)) != sizeof(info)) { + if (query_region(reinterpret_cast(cursor), &info, sizeof(info)) != sizeof(info)) { result.code = kMemoryAccessDenied; return result; } @@ -263,7 +422,7 @@ ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) request.pattern.size() - 1, region_end - after_unique); const auto read_bytes = static_cast(unique_bytes + lookahead); std::size_t copied = 0; - if (!read_chunk(reinterpret_cast(cursor), scan_buffer.get(), read_bytes, + if (!read_chunk(reinterpret_cast(cursor), scan_buffer->data(), read_bytes, copied) || copied != read_bytes) { result.code = kMemoryAccessDenied; return result; @@ -279,7 +438,7 @@ ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) OverlapsRange(match_address, request.pattern.size(), request.mask.data(), request.mask.size()) || OverlapsMatchContext(match_address, request.pattern.size(), result) || - !PatternMatches(scan_buffer.get() + offset, request)) { + !PatternMatches(scan_buffer->data() + offset, request)) { continue; } @@ -296,8 +455,22 @@ ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) match.region_size = info.RegionSize; match.protection = info.Protect; match.context_address = FormatAddress(cursor + context_start); - match.context.assign(scan_buffer.get() + context_start, - scan_buffer.get() + context_end); + auto context = MappedBytes::CopyFrom(std::span( + scan_buffer->data() + context_start, context_end - context_start)); + if (!context) { + result.code = kMemoryAccessDenied; + return result; + } + match.context = std::move(*context); + if (request.include_allocation_metadata) { + match.allocation = ResolveAllocationMetadata( + match_address, info, maximum, query_region, allocation_extents); + if (!match.allocation) { + result.matches.clear(); + result.code = kMemoryAccessDenied; + return result; + } + } result.matches.push_back(std::move(match)); if (result.matches.size() > request.max_matches) { diff --git a/native/host/memory_reader.h b/native/host/memory_reader.h index 2d1a727..99ce256 100644 --- a/native/host/memory_reader.h +++ b/native/host/memory_reader.h @@ -5,12 +5,37 @@ #include #include #include +#include #include #include #include namespace cfb27::memory { +class MappedBytes { + public: + MappedBytes() = default; + ~MappedBytes(); + MappedBytes(MappedBytes&&) noexcept; + MappedBytes& operator=(MappedBytes&&) noexcept; + MappedBytes(const MappedBytes&) = delete; + MappedBytes& operator=(const MappedBytes&) = delete; + static std::optional Allocate(std::size_t size); + static std::optional FromUpperHex(std::string_view text); + static std::optional CopyFrom(std::span bytes); + const std::uint8_t* data() const; + std::uint8_t* data(); + std::size_t size() const; + bool empty() const; + std::span bytes() const; + std::span mutable_bytes(); + + private: + HANDLE mapping_{}; + std::uint8_t* view_{}; + std::size_t size_{}; +}; + constexpr std::size_t kMinPatternBytes = 8; constexpr std::size_t kMaxPatternBytes = 4096; constexpr std::size_t kMaxMatches = 64; @@ -21,6 +46,8 @@ constexpr std::size_t kMaxReadRanges = 64; constexpr std::size_t kMaxReadRangeBytes = 64ull * 1024; constexpr std::size_t kMaxReadBytes = 256ull * 1024; +std::optional DecodeScanHex(std::string_view text); + namespace detail { enum class ScanPageBoundary { @@ -55,16 +82,27 @@ struct BatchReadResult { }; struct ScanRequest { - std::vector pattern; - std::vector mask; + MappedBytes pattern; + MappedBytes mask; std::size_t max_matches{}; std::size_t context_before{}; std::size_t context_after{}; std::optional cursor; + bool include_allocation_metadata{}; }; using ScanReadFunction = bool (*)(const void* source, void* destination, std::size_t length, std::size_t& copied); +using ScanQueryFunction = SIZE_T (*)(const void* address, + MEMORY_BASIC_INFORMATION* information, + SIZE_T length); + +struct AllocationMetadata { + std::string base; + std::size_t size{}; + DWORD protection{}; + std::size_t offset{}; +}; struct ScanMatch { std::string address; @@ -72,7 +110,8 @@ struct ScanMatch { std::size_t region_size{}; DWORD protection{}; std::string context_address; - std::vector context; + MappedBytes context; + std::optional allocation; }; struct ScanResult { @@ -88,6 +127,7 @@ std::string FormatAddress(std::uintptr_t address); bool IsEligiblePrivateReadableRegion(const MEMORY_BASIC_INFORMATION& info); BatchReadResult ReadMemoryBatch(const std::vector& ranges); ScanResult ScanPrivateMemory(const ScanRequest& request, - ScanReadFunction read = nullptr); + ScanReadFunction read = nullptr, + ScanQueryFunction query = nullptr); } // namespace cfb27::memory diff --git a/native/host/memory_transaction.cpp b/native/host/memory_transaction.cpp new file mode 100644 index 0000000..174b9d6 --- /dev/null +++ b/native/host/memory_transaction.cpp @@ -0,0 +1,243 @@ +#include "memory_transaction.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace cfb27::memory { +namespace { + +struct PreparedOperation { + std::uintptr_t address{}; + std::vector original; +}; + +struct Range { + std::uintptr_t begin{}; + std::uintptr_t end{}; +}; + +bool IsValidTransactionId(std::string_view id) { + if (id.empty() || id.size() > 64) return false; + return std::all_of(id.begin(), id.end(), [](const char character) { + return (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || character == '.' || + character == '_' || character == '-'; + }); +} + +std::optional ParseAddress(std::string_view text) { + if (text.size() <= 2 || text[0] != '0' || text[1] != 'x' || + (text.size() > 3 && text[2] == '0') || + !std::all_of(text.begin() + 2, text.end(), [](const char character) { + return (character >= '0' && character <= '9') || + (character >= 'A' && character <= 'F'); + })) { + return std::nullopt; + } + std::uintptr_t address{}; + const char* first = text.data() + 2; + const char* last = text.data() + text.size(); + const auto [end, error] = std::from_chars(first, last, address, 16); + if (error != std::errc{} || end != last) return std::nullopt; + return address; +} + +TransactionResult Rejected(std::string code) { + return {.status = TransactionStatus::kRejected, .code = std::move(code)}; +} + +TransactionResult RollBack( + const std::vector& prepared, + const std::vector& attempted, + std::vector results, MemoryBackend& backend) { + bool restored = true; + for (auto iterator = attempted.rbegin(); iterator != attempted.rend(); + ++iterator) { + const auto index = *iterator; + if (!backend.Write(prepared[index].address, prepared[index].original)) { + restored = false; + } + results[index].applied = false; + results[index].verified = false; + } + + for (const auto index : attempted) { + std::vector readback(prepared[index].original.size()); + if (!backend.Read(prepared[index].address, readback) || + readback != prepared[index].original) { + restored = false; + } + } + + return { + .status = restored ? TransactionStatus::kRolledBackVerified + : TransactionStatus::kRollbackUnverified, + .code = restored ? "rolled_back_verified" : "rollback_unverified", + .operations = std::move(results), + }; +} + +bool IsReadableProtection(DWORD protection) { + const DWORD base = protection & 0xFF; + return base == PAGE_READONLY || base == PAGE_READWRITE || + base == PAGE_WRITECOPY || base == PAGE_EXECUTE_READ || + base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; +} + +bool IsWritableProtection(DWORD protection) { + const DWORD base = protection & 0xFF; + return base == PAGE_READWRITE || base == PAGE_WRITECOPY || + base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; +} + +} // namespace + +bool ProcessMemoryBackend::Validate(std::uintptr_t address, std::size_t size, + bool writable) { + if (size == 0 || address > std::numeric_limits::max() - size) { + return false; + } + const auto end = address + size; + auto cursor = address; + while (cursor < end) { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(cursor), &info, + sizeof(info)) != sizeof(info) || + info.State != MEM_COMMIT || (info.Protect & PAGE_GUARD) != 0 || + (info.Protect & PAGE_NOACCESS) != 0 || + !IsReadableProtection(info.Protect) || + (writable && !IsWritableProtection(info.Protect))) { + return false; + } + const auto region_begin = reinterpret_cast(info.BaseAddress); + if (info.RegionSize > + std::numeric_limits::max() - region_begin) { + return false; + } + const auto region_end = region_begin + info.RegionSize; + if (region_end <= cursor) return false; + cursor = std::min(end, region_end); + } + return true; +} + +bool ProcessMemoryBackend::Read(std::uintptr_t address, + std::span output) { + SIZE_T copied{}; + return ReadProcessMemory(GetCurrentProcess(), + reinterpret_cast(address), + output.data(), output.size(), &copied) != FALSE && + copied == output.size(); +} + +bool ProcessMemoryBackend::Write(std::uintptr_t address, + std::span input) { + SIZE_T copied{}; + return WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(address), + input.data(), input.size(), &copied) != FALSE && + copied == input.size(); +} + +TransactionResult RunTransaction(const TransactionRequest& request, + MemoryBackend& backend) { + if (!IsValidTransactionId(request.transaction_id)) { + return Rejected("invalid_transaction_id"); + } + if (request.operations.empty() || + request.operations.size() > kMaxTransactionOperations) { + return Rejected("invalid_operation_count"); + } + + std::size_t aggregate_bytes{}; + std::vector prepared(request.operations.size()); + std::vector ranges; + ranges.reserve(request.operations.size()); + for (std::size_t index = 0; index < request.operations.size(); ++index) { + const auto& operation = request.operations[index]; + if (operation.expected.empty() || + operation.expected.size() != operation.replacement.size() || + operation.replacement.size() > kMaxOperationBytes) { + return Rejected("invalid_operation_size"); + } + if (aggregate_bytes > + kMaxTransactionBytes - operation.replacement.size()) { + return Rejected("transaction_too_large"); + } + aggregate_bytes += operation.replacement.size(); + + const auto address = ParseAddress(operation.address); + if (!address || *address > std::numeric_limits::max() - + operation.replacement.size()) { + return Rejected("invalid_address"); + } + prepared[index].address = *address; + ranges.push_back({*address, *address + operation.replacement.size()}); + } + + std::sort(ranges.begin(), ranges.end(), [](const Range& left, + const Range& right) { + return left.begin < right.begin; + }); + for (std::size_t index = 1; index < ranges.size(); ++index) { + if (ranges[index].begin < ranges[index - 1].end) { + return Rejected("overlapping_operations"); + } + } + + for (std::size_t index = 0; index < prepared.size(); ++index) { + if (!backend.Validate(prepared[index].address, + request.operations[index].replacement.size(), true)) { + return Rejected("invalid_memory_range"); + } + } + for (std::size_t index = 0; index < prepared.size(); ++index) { + prepared[index].original.resize(request.operations[index].expected.size()); + if (!backend.Read(prepared[index].address, prepared[index].original)) { + return Rejected("preflight_read_failed"); + } + } + for (std::size_t index = 0; index < prepared.size(); ++index) { + if (prepared[index].original != request.operations[index].expected) { + return Rejected("expected_mismatch"); + } + } + + std::vector results; + results.reserve(request.operations.size()); + for (std::size_t index = 0; index < request.operations.size(); ++index) { + results.push_back({.index = index}); + } + + std::vector attempted; + attempted.reserve(request.operations.size()); + for (std::size_t index = 0; index < request.operations.size(); ++index) { + attempted.push_back(index); + if (!backend.Write(prepared[index].address, + request.operations[index].replacement)) { + return RollBack(prepared, attempted, std::move(results), backend); + } + results[index].applied = true; + std::vector readback( + request.operations[index].replacement.size()); + if (!backend.Read(prepared[index].address, readback) || + readback != request.operations[index].replacement) { + return RollBack(prepared, attempted, std::move(results), backend); + } + results[index].verified = true; + } + + return { + .status = TransactionStatus::kAppliedVerified, + .code = "applied_verified", + .operations = std::move(results), + }; +} + +} // namespace cfb27::memory diff --git a/native/host/memory_transaction.h b/native/host/memory_transaction.h new file mode 100644 index 0000000..b3fc1c6 --- /dev/null +++ b/native/host/memory_transaction.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cfb27::memory { + +constexpr std::size_t kMaxTransactionOperations = 32; +constexpr std::size_t kMaxOperationBytes = 4096; +constexpr std::size_t kMaxTransactionBytes = 64ull * 1024; + +struct TransactionOperation { + std::string address; + std::vector expected; + std::vector replacement; +}; + +struct TransactionRequest { + std::string transaction_id; + std::vector operations; +}; + +enum class TransactionStatus { + kRejected, + kAppliedVerified, + kRolledBackVerified, + kRollbackUnverified, +}; + +struct OperationResult { + std::size_t index{}; + bool applied{}; + bool verified{}; +}; + +struct TransactionResult { + TransactionStatus status{}; + std::string code; + std::vector operations; +}; + +class MemoryBackend { + public: + virtual ~MemoryBackend() = default; + virtual bool Validate(std::uintptr_t address, std::size_t size, + bool writable) = 0; + virtual bool Read(std::uintptr_t address, + std::span output) = 0; + virtual bool Write(std::uintptr_t address, + std::span input) = 0; +}; + +class ProcessMemoryBackend final : public MemoryBackend { + public: + bool Validate(std::uintptr_t address, std::size_t size, + bool writable) override; + bool Read(std::uintptr_t address, + std::span output) override; + bool Write(std::uintptr_t address, + std::span input) override; +}; + +TransactionResult RunTransaction(const TransactionRequest& request, + MemoryBackend& backend); + +} // namespace cfb27::memory diff --git a/native/smoke/memory_reader_smoke.cpp b/native/smoke/memory_reader_smoke.cpp index 9b8b8e8..06a8b1e 100644 --- a/native/smoke/memory_reader_smoke.cpp +++ b/native/smoke/memory_reader_smoke.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -18,8 +19,25 @@ using cfb27::memory::ReadMemoryBatch; using cfb27::memory::ScanPrivateMemory; std::uintptr_t g_fail_read_at{}; +std::uintptr_t g_fail_query_at{}; std::uintptr_t g_scan_destination{}; bool g_attempted_scan_buffer_read{}; +bool g_scan_destination_is_mapped{}; + +bool IsReadableProtection(DWORD protection) { + if ((protection & (PAGE_GUARD | PAGE_NOACCESS)) != 0) return false; + switch (protection & 0xFF) { + case PAGE_READONLY: + case PAGE_READWRITE: + case PAGE_WRITECOPY: + case PAGE_EXECUTE_READ: + case PAGE_EXECUTE_READWRITE: + case PAGE_EXECUTE_WRITECOPY: + return true; + default: + return false; + } +} bool TestRead(const void* source, void* destination, std::size_t length, std::size_t& copied) { @@ -27,6 +45,11 @@ bool TestRead(const void* source, void* destination, std::size_t length, const auto begin = reinterpret_cast(source); if (g_scan_destination == 0) { g_scan_destination = reinterpret_cast(destination); + MEMORY_BASIC_INFORMATION info{}; + g_scan_destination_is_mapped = + VirtualQuery(destination, &info, sizeof(info)) == sizeof(info) && + info.State == MEM_COMMIT && info.Type == MEM_MAPPED && + IsReadableProtection(info.Protect); } else if (g_scan_destination >= begin && g_scan_destination - begin < length) { g_attempted_scan_buffer_read = true; } @@ -40,6 +63,21 @@ void Require(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); } +SIZE_T TestQuery(const void* address, MEMORY_BASIC_INFORMATION* info, + SIZE_T length) { + if (reinterpret_cast(address) == g_fail_query_at) return 0; + return VirtualQuery(address, info, length); +} + +void RequireMappedStorage(const void* pointer, const char* message) { + MEMORY_BASIC_INFORMATION info{}; + Require(pointer != nullptr && + VirtualQuery(pointer, &info, sizeof(info)) == sizeof(info) && + info.State == MEM_COMMIT && info.Type == MEM_MAPPED && + IsReadableProtection(info.Protect), + message); +} + std::vector HexBytes(const std::string& text) { Require(text.size() % 2 == 0, "even hex byte text"); std::vector bytes; @@ -50,6 +88,25 @@ std::vector HexBytes(const std::string& text) { return bytes; } +cfb27::memory::MappedBytes MappedHex(std::string_view text) { + auto bytes = cfb27::memory::MappedBytes::FromUpperHex(text); + Require(bytes.has_value(), "mapped hex bytes"); + return std::move(*bytes); +} + +cfb27::memory::MappedBytes MappedFill(std::size_t size, std::uint8_t value) { + auto bytes = cfb27::memory::MappedBytes::Allocate(size); + Require(bytes.has_value(), "mapped filled bytes"); + std::fill(bytes->mutable_bytes().begin(), bytes->mutable_bytes().end(), value); + return std::move(*bytes); +} + +cfb27::memory::MappedBytes MappedCopy(std::span source) { + auto bytes = cfb27::memory::MappedBytes::CopyFrom(source); + Require(bytes.has_value(), "mapped copied bytes"); + return std::move(*bytes); +} + class Allocation { public: Allocation(std::size_t size, DWORD protection = PAGE_READWRITE) @@ -70,6 +127,9 @@ class Allocation { void* address_{}; }; +std::size_t CountAddress(const std::vector& matches, + const void* address); + void TestAddressParsing() { const auto value = reinterpret_cast(&TestAddressParsing); const auto formatted = FormatAddress(value); @@ -107,8 +167,8 @@ void TestScanAndRead() { other.shrink_to_fit(); const auto scan = ScanPrivateMemory({ - .pattern = HexBytes("CFB27A1100A1B2C3D4E5F60718293A4B"), - .mask = std::vector(16, 0xFF), + .pattern = MappedHex("CFB27A1100A1B2C3D4E5F60718293A4B"), + .mask = MappedFill(16, 0xFF), .max_matches = 2, .context_before = 4, .context_after = 4, @@ -124,15 +184,15 @@ void TestScanAndRead() { "batch read"); const auto short_pattern = ScanPrivateMemory({ - .pattern = std::vector(7, 0x11), - .mask = std::vector(7, 0xFF), + .pattern = MappedFill(7, 0x11), + .mask = MappedFill(7, 0xFF), .max_matches = 1, }); Require(!short_pattern.complete, "7-byte pattern rejection"); const auto excessive_matches = ScanPrivateMemory({ - .pattern = std::vector(8, 0x11), - .mask = std::vector(8, 0xFF), + .pattern = MappedFill(8, 0x11), + .mask = MappedFill(8, 0xFF), .max_matches = 65, }); Require(!excessive_matches.complete, "65 requested matches rejection"); @@ -140,17 +200,22 @@ void TestScanAndRead() { void TestScanExcludesMaskBuffer() { cfb27::memory::ScanRequest request{ - .pattern = std::vector(4096, 0xFF), - .mask = std::vector(4096, 0xFF), + .pattern = MappedHex(std::string(8192, 'F')), + .mask = MappedHex(std::string(8192, 'F')), .max_matches = 2, }; const auto mask_begin = reinterpret_cast(request.mask.data()); const auto mask_end = mask_begin + request.mask.size(); + RequireMappedStorage(request.pattern.data(), "decoded pattern uses mapped storage"); + RequireMappedStorage(request.mask.data(), "decoded mask uses mapped storage"); + g_scan_destination = 0; g_attempted_scan_buffer_read = false; + g_scan_destination_is_mapped = false; const auto scan = ScanPrivateMemory(request, TestRead); Require(scan.complete, "mask buffer exclusion scan completes"); + Require(g_scan_destination_is_mapped, "scan read destination uses mapped storage"); Require(!g_attempted_scan_buffer_read, "dedicated scan buffer excluded from traversal"); for (const auto& match : scan.matches) { const auto address = cfb27::memory::ParseAddress(match.address); @@ -159,6 +224,107 @@ void TestScanExcludesMaskBuffer() { } } +void TestScanHexDecodeRejectsBeforeMappedAllocation() { + const std::string oversized_hex( + (cfb27::memory::kMaxPatternBytes + 1) * 2, 'F'); + Require(!cfb27::memory::DecodeScanHex(oversized_hex), + "scan hex decoder rejects over-limit bytes before allocation"); + + const auto unrestricted = + cfb27::memory::MappedBytes::FromUpperHex(oversized_hex); + Require(unrestricted && + unrestricted->size() == cfb27::memory::kMaxPatternBytes + 1, + "general mapped hex decoding remains unrestricted"); +} + +void TestAllocationTopology() { + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + const auto page_size = static_cast(system_info.dwPageSize); + auto* allocation = static_cast( + VirtualAlloc(nullptr, page_size * 3, MEM_RESERVE, PAGE_READWRITE)); + Require(allocation != nullptr, "reserve topology allocation"); + Require(VirtualAlloc(allocation, page_size, MEM_COMMIT, PAGE_READWRITE) == allocation, + "commit first topology page"); + Require(VirtualAlloc(allocation + page_size, page_size, MEM_COMMIT, PAGE_READWRITE) == + allocation + page_size, + "commit middle topology page"); + Require(VirtualAlloc(allocation + page_size * 2, page_size, MEM_COMMIT, PAGE_READWRITE) == + allocation + page_size * 2, + "commit final topology page"); + DWORD prior{}; + Require(VirtualProtect(allocation, page_size, PAGE_READONLY, &prior) != FALSE, + "protect first topology page"); + Require(VirtualProtect(allocation + page_size * 2, page_size, PAGE_EXECUTE_READ, + &prior) != FALSE, + "protect final topology page"); + + auto sentinel = HexBytes("A93E710CF4B8256D013579BDF2468ACE"); + auto* target = allocation + page_size + 128; + std::memcpy(target, sentinel.data(), sentinel.size()); + cfb27::memory::ScanRequest request{ + .pattern = MappedCopy(sentinel), + .mask = MappedFill(sentinel.size(), 0xFF), + .max_matches = 1, + .cursor = FormatAddress(reinterpret_cast(allocation)), + .include_allocation_metadata = true, + }; + SecureZeroMemory(sentinel.data(), sentinel.size()); + sentinel.clear(); + sentinel.shrink_to_fit(); + + const auto scan = ScanPrivateMemory(request, TestRead, TestQuery); + Require(scan.code.empty() && CountAddress(scan.matches, target) == 1, + "allocation topology match found"); + const auto& metadata = *scan.matches[0].allocation; + Require(metadata.base == FormatAddress(reinterpret_cast(allocation)), + "allocation topology base"); + Require(metadata.size == page_size * 3, "allocation topology full extent"); + Require(metadata.protection == PAGE_READWRITE, "allocation topology initial protection"); + Require(metadata.offset == page_size + 128, "allocation topology checked offset"); + + g_fail_query_at = reinterpret_cast(allocation) + page_size * 3; + const auto failed = ScanPrivateMemory(request, TestRead, TestQuery); + Require(failed.code == "MEMORY_ACCESS_DENIED" && failed.matches.empty(), + "allocation extent query failure discards matches"); + g_fail_query_at = 0; + VirtualFree(allocation, 0, MEM_RELEASE); +} + +void TestRetainedContextCannotSelfMatch() { + constexpr std::size_t kAllocationSize = 64 * 1024; + void* allocation = + VirtualAlloc(nullptr, kAllocationSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + Require(allocation != nullptr, "allocate retained-context target"); + auto sentinel = HexBytes("E37A91C5B2046DF80A1B2C3D4E5F6071"); + std::memcpy(static_cast(allocation) + 128, sentinel.data(), sentinel.size()); + + cfb27::memory::ScanRequest request{ + .pattern = MappedCopy(sentinel), + .mask = MappedFill(sentinel.size(), 0xFF), + .max_matches = 1, + .context_before = 4, + .context_after = 4, + .cursor = FormatAddress(reinterpret_cast(allocation)), + }; + SecureZeroMemory(sentinel.data(), sentinel.size()); + sentinel.clear(); + sentinel.shrink_to_fit(); + + auto first = ScanPrivateMemory(request); + Require(first.matches.size() == 1, "retained-context first match"); + RequireMappedStorage(first.matches[0].context.data(), + "produced match context uses mapped storage"); + const auto retained_address = first.matches[0].context.data() + request.context_before; + request.cursor = FormatAddress( + reinterpret_cast(first.matches[0].context.data())); + VirtualFree(allocation, 0, MEM_RELEASE); + + const auto second = ScanPrivateMemory(request); + Require(CountAddress(second.matches, retained_address) == 0, + "retained prior context is not returned as a later match"); +} + std::size_t CountAddress(const std::vector& matches, const void* address) { const auto expected = reinterpret_cast(address); @@ -182,8 +348,8 @@ void TestPagedLargeRegionAndBoundaries() { std::memcpy(bytes + old_tail, sentinel.data(), sentinel.size()); cfb27::memory::ScanRequest request{ - .pattern = sentinel, - .mask = mask, + .pattern = MappedCopy(sentinel), + .mask = MappedCopy(mask), .max_matches = 8, .context_before = 4, .context_after = 4, @@ -195,11 +361,13 @@ void TestPagedLargeRegionAndBoundaries() { for (std::size_t pages = 0; pages < 4096; ++pages) { const auto input_cursor = cfb27::memory::ParseAddress(*request.cursor); Require(input_cursor.has_value(), "page input cursor is valid"); - const auto result = ScanPrivateMemory(request); + auto result = ScanPrivateMemory(request); Require(result.code.empty(), "large-region page succeeds"); Require(result.scanned_bytes <= cfb27::memory::kMaxScanPageBytes, "scan page is bounded"); - matches.insert(matches.end(), result.matches.begin(), result.matches.end()); + matches.insert(matches.end(), + std::make_move_iterator(result.matches.begin()), + std::make_move_iterator(result.matches.end())); if (result.complete) { Require(!result.next_cursor.has_value(), "complete page has no cursor"); completed = true; @@ -239,8 +407,8 @@ void TestInvalidPageCursors() { reinterpret_cast(system_info.lpMaximumApplicationAddress); const auto above_maximum = FormatAddress(maximum + 1); const auto result = ScanPrivateMemory({ - .pattern = HexBytes("A1B2C3D4E5F60718"), - .mask = std::vector(8, 0xFF), + .pattern = MappedHex("A1B2C3D4E5F60718"), + .mask = MappedFill(8, 0xFF), .max_matches = 1, .cursor = above_maximum, }); @@ -248,8 +416,8 @@ void TestInvalidPageCursors() { "cursor above system maximum rejected"); const auto overflowing = ScanPrivateMemory({ - .pattern = HexBytes("A1B2C3D4E5F60718"), - .mask = std::vector(8, 0xFF), + .pattern = MappedHex("A1B2C3D4E5F60718"), + .mask = MappedFill(8, 0xFF), .max_matches = 1, .cursor = "0x10000000000000000", }); @@ -270,8 +438,8 @@ void TestInvalidPageCursors() { }; for (const auto& cursor : noncanonical) { const auto rejected = ScanPrivateMemory({ - .pattern = HexBytes("A1B2C3D4E5F60718"), - .mask = std::vector(8, 0xFF), + .pattern = MappedHex("A1B2C3D4E5F60718"), + .mask = MappedFill(8, 0xFF), .max_matches = 1, .cursor = cursor, }); @@ -346,8 +514,8 @@ void TestPagedScanBeyondOldAggregateLimit() { std::memcpy(static_cast(regions.back()) + kTargetOffset, sentinel.data(), sentinel.size()); cfb27::memory::ScanRequest request{ - .pattern = sentinel, - .mask = std::vector(16, 0xFF), + .pattern = MappedCopy(sentinel), + .mask = MappedFill(16, 0xFF), .max_matches = 1, .cursor = FormatAddress(reinterpret_cast(regions.front())), }; @@ -379,9 +547,12 @@ void TestPagedScanBeyondOldAggregateLimit() { int main() { try { TestAddressParsing(); + TestScanHexDecodeRejectsBeforeMappedAllocation(); TestRegionEligibility(); TestScanAndRead(); + TestAllocationTopology(); TestScanExcludesMaskBuffer(); + TestRetainedContextCannotSelfMatch(); TestPagedLargeRegionAndBoundaries(); TestInvalidPageCursors(); TestTerminalCompletionPrecedesPageBudget(); diff --git a/native/smoke/memory_transaction_smoke.cpp b/native/smoke/memory_transaction_smoke.cpp new file mode 100644 index 0000000..8458d30 --- /dev/null +++ b/native/smoke/memory_transaction_smoke.cpp @@ -0,0 +1,276 @@ +#include "../host/memory_transaction.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using cfb27::memory::MemoryBackend; +using cfb27::memory::RunTransaction; +using cfb27::memory::TransactionOperation; +using cfb27::memory::TransactionRequest; +using Status = cfb27::memory::TransactionStatus; + +void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +class FakeMemoryBackend final : public MemoryBackend { + public: + FakeMemoryBackend() { + for (std::size_t index = 0; index < original.size(); ++index) { + original[index] = static_cast(index); + } + Reset(); + } + + void Reset() { + bytes = original; + write_calls = 0; + read_calls = 0; + fail_write_index = -1; + fail_verification_read = false; + fail_restore = false; + fail_restore_read = false; + rollback_started = false; + write_addresses.clear(); + apply_calls.clear(); + } + + bool Validate(std::uintptr_t address, std::size_t size, bool) override { + return address <= bytes.size() && size <= bytes.size() - address; + } + + bool Read(std::uintptr_t address, std::span output) override { + ++read_calls; + if (write_calls > 0) { + apply_calls.push_back("read:" + std::to_string(address)); + } + if (fail_verification_read && write_calls > 0 && !rollback_started) { + fail_verification_read = false; + rollback_started = true; + return false; + } + if (fail_restore_read && rollback_started) return false; + if (!Validate(address, output.size(), false)) return false; + std::copy_n(bytes.begin() + address, output.size(), output.begin()); + return true; + } + + bool Write(std::uintptr_t address, + std::span input) override { + const int call_index = write_calls++; + write_addresses.push_back(address); + apply_calls.push_back("write:" + std::to_string(address)); + if (!rollback_started && call_index == fail_write_index) { + rollback_started = true; + return false; + } + if (rollback_started && fail_restore) return false; + if (!Validate(address, input.size(), true)) return false; + std::copy(input.begin(), input.end(), bytes.begin() + address); + return true; + } + + std::array original{}; + std::array bytes{}; + int write_calls{}; + int read_calls{}; + int fail_write_index{-1}; + bool fail_verification_read{}; + bool fail_restore{}; + bool fail_restore_read{}; + bool rollback_started{}; + std::vector write_addresses; + std::vector apply_calls; +}; + +TransactionRequest ValidRequest(const FakeMemoryBackend& backend) { + return { + .transaction_id = "smoke.valid-1", + .operations = { + {.address = "0x10", + .expected = {backend.original[0x10], backend.original[0x11]}, + .replacement = {0xA1, 0xA2}}, + {.address = "0x20", + .expected = {backend.original[0x20], backend.original[0x21], + backend.original[0x22]}, + .replacement = {0xB1, 0xB2, 0xB3}}, + }, + }; +} + +void RequireRejectedWithoutWrites(const TransactionRequest& request, + const char* message) { + FakeMemoryBackend backend; + const auto result = RunTransaction(request, backend); + Require(result.status == Status::kRejected && backend.write_calls == 0, + message); +} + +void TestApplyAndPreflight() { + FakeMemoryBackend backend; + const auto valid = ValidRequest(backend); + auto expected_after = backend.original; + expected_after[0x10] = 0xA1; + expected_after[0x11] = 0xA2; + expected_after[0x20] = 0xB1; + expected_after[0x21] = 0xB2; + expected_after[0x22] = 0xB3; + + Require(RunTransaction(valid, backend).status == Status::kAppliedVerified, + "happy path"); + Require(backend.bytes == expected_after, "replacement present"); + Require(backend.apply_calls == std::vector({ + "write:16", "read:16", "write:32", "read:32"}), + "each operation is verified before the next write"); + + backend.Reset(); + backend.bytes[0x10] ^= 1; + const auto mismatch = RunTransaction(valid, backend); + Require(mismatch.status == Status::kRejected && backend.write_calls == 0, + "preflight mismatch writes nothing"); +} + +void TestRollbackOutcomes() { + FakeMemoryBackend backend; + const auto valid = ValidRequest(backend); + + backend.Reset(); + backend.fail_write_index = 1; + const auto rolled_back = RunTransaction(valid, backend); + Require(rolled_back.status == Status::kRolledBackVerified, + "failed apply rolls back"); + Require(backend.bytes == backend.original, "all originals restored"); + Require(backend.write_addresses == + std::vector({0x10, 0x20, 0x20, 0x10}), + "attempted writes restore in reverse order"); + + backend.Reset(); + backend.fail_write_index = 1; + backend.fail_restore = true; + Require(RunTransaction(valid, backend).status == Status::kRollbackUnverified, + "rollback failure is explicit"); + + backend.Reset(); + backend.fail_verification_read = true; + Require(RunTransaction(valid, backend).status == Status::kRolledBackVerified, + "verification read failure rolls back"); + Require(backend.bytes == backend.original, + "verification read failure restores originals"); + Require(backend.write_addresses == + std::vector({0x10, 0x10}), + "first verification failure never attempts the second write"); + Require(backend.apply_calls == std::vector({ + "write:16", "read:16", "write:16", "read:16"}), + "failed first operation is restored and verified"); + + backend.Reset(); + backend.fail_write_index = 1; + backend.fail_restore_read = true; + Require(RunTransaction(valid, backend).status == Status::kRollbackUnverified, + "restore read failure is explicit"); +} + +void TestRequestValidation() { + FakeMemoryBackend backend; + const auto valid = ValidRequest(backend); + + auto request = valid; + request.operations.clear(); + RequireRejectedWithoutWrites(request, "empty operations rejected"); + + request = valid; + request.operations.assign(33, valid.operations.front()); + RequireRejectedWithoutWrites(request, "33 operations rejected"); + + request = valid; + request.operations = {{.address = "0x0", + .expected = std::vector(4097, 1), + .replacement = std::vector(4097, 2)}}; + RequireRejectedWithoutWrites(request, "4097-byte operation rejected"); + + request = valid; + request.operations.clear(); + for (std::size_t index = 0; index < 17; ++index) { + request.operations.push_back( + {.address = "0x0", + .expected = std::vector(4096, 1), + .replacement = std::vector(4096, 2)}); + } + RequireRejectedWithoutWrites(request, "aggregate above 64 KiB rejected"); + + request = valid; + request.operations[0].replacement.push_back(0xFF); + RequireRejectedWithoutWrites(request, "unequal lengths rejected"); + + request = valid; + request.operations = {{ + .address = "0xFFFFFFFFFFFFFFFF", + .expected = {1, 2}, + .replacement = {3, 4}, + }}; + RequireRejectedWithoutWrites(request, "overflowed range rejected"); + + for (const auto& noncanonical_address : + std::vector{"0x1a", "0X10", "0x010"}) { + request = valid; + request.operations[0].address = noncanonical_address; + RequireRejectedWithoutWrites(request, "noncanonical address rejected"); + } + + request = valid; + request.operations.push_back(valid.operations.front()); + RequireRejectedWithoutWrites(request, "duplicate range rejected"); + + request = valid; + request.operations[1].address = "0x11"; + RequireRejectedWithoutWrites(request, "ascending overlap rejected"); + + request = valid; + std::swap(request.operations[0], request.operations[1]); + request.operations[1].address = "0x21"; + RequireRejectedWithoutWrites(request, "descending overlap rejected"); + + for (const auto& invalid_id : + std::vector{"", std::string(65, 'a'), "invalid/id"}) { + request = valid; + request.transaction_id = invalid_id; + RequireRejectedWithoutWrites(request, "invalid transaction ID rejected"); + } +} + +void TestNonOverlappingRequestOrderIsPreserved() { + FakeMemoryBackend backend; + auto request = ValidRequest(backend); + std::swap(request.operations[0], request.operations[1]); + const auto result = RunTransaction(request, backend); + Require(result.status == Status::kAppliedVerified, + "descending non-overlapping operations accepted"); + Require(backend.write_addresses == + std::vector({0x20, 0x10}), + "caller operation order preserved"); +} + +} // namespace + +int main() { + try { + TestApplyAndPreflight(); + TestRollbackOutcomes(); + TestRequestValidation(); + TestNonOverlappingRequestOrderIsPreserved(); + std::cout << "memory transaction smoke passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "memory transaction smoke failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 890f4ce..7e542dd 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -3,12 +3,15 @@ #include #include +#include #include #include #include #include +#include #include #include +#include #include using Json = nlohmann::json; @@ -36,6 +39,40 @@ class Allocation { void* address_{}; }; +class TopologyAllocation { + public: + TopologyAllocation() { + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + page_size_ = static_cast(system_info.dwPageSize); + address_ = static_cast( + VirtualAlloc(nullptr, page_size_ * 3, MEM_RESERVE, PAGE_READWRITE)); + if (!address_) return; + for (std::size_t page = 0; page < 3; ++page) { + if (VirtualAlloc(address_ + page * page_size_, page_size_, MEM_COMMIT, + PAGE_READWRITE) != address_ + page * page_size_) return; + } + DWORD prior{}; + if (!VirtualProtect(address_, page_size_, PAGE_READONLY, &prior) || + !VirtualProtect(address_ + page_size_ * 2, page_size_, PAGE_EXECUTE_READ, + &prior)) return; + valid_ = true; + } + ~TopologyAllocation() { + if (address_) VirtualFree(address_, 0, MEM_RELEASE); + } + TopologyAllocation(const TopologyAllocation&) = delete; + TopologyAllocation& operator=(const TopologyAllocation&) = delete; + bool valid() const { return valid_; } + std::uint8_t* get() const { return address_; } + std::size_t page_size() const { return page_size_; } + + private: + std::uint8_t* address_{}; + std::size_t page_size_{}; + bool valid_{}; +}; + std::string FormatAddress(std::uintptr_t address) { std::ostringstream out; out << "0x" << std::uppercase << std::hex << address; @@ -151,14 +188,85 @@ bool RequestOversizedFrame(const std::wstring& pipe_name, Json& response) { return ok; } +bool LegacyEvaluate(const std::wstring& pipe_name, std::string_view source, + Json& response) { + HANDLE pipe = OpenPipe(pipe_name); + if (pipe == INVALID_HANDLE_VALUE) return false; + const std::string request = "EVAL " + std::string(source); + DWORD written = 0; + std::array buffer{}; + DWORD read = 0; + const bool ok = WriteFile(pipe, request.data(), static_cast(request.size()), + &written, nullptr) && + written == request.size() && + ReadFile(pipe, buffer.data(), static_cast(buffer.size() - 1), + &read, nullptr); + CloseHandle(pipe); + if (!ok) return false; + response = Json::parse(buffer.data(), buffer.data() + read, nullptr, false); + return !response.is_discarded(); +} + +bool BoundedRequest(const std::wstring& pipe_name, Json request, Json& response, + DWORD timeout_ms = 5000) { + struct State { + Json response; + bool ok{}; + }; + auto state = std::make_shared(); + HANDLE completed = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!completed) return false; + std::thread worker([pipe_name, request = std::move(request), state, completed] { + state->ok = Request(pipe_name, request, state->response, false); + SetEvent(completed); + }); + if (WaitForSingleObject(completed, timeout_ms) != WAIT_OBJECT_0) { + worker.detach(); + return false; + } + worker.join(); + CloseHandle(completed); + response = std::move(state->response); + return state->ok; +} + int wmain(int argc, wchar_t** argv) { + wchar_t smoke_gate[8]{}; + if (GetEnvironmentVariableW(L"CFB27_SMOKE_ALLOW_WRITES", smoke_gate, + static_cast(std::size(smoke_gate))) != 1 || + smoke_gate[0] != L'1') return 72; + Allocation transaction_one(4096); + Allocation transaction_two(4096); + if (!transaction_one.get() || !transaction_two.get()) return 73; + auto* transaction_one_bytes = static_cast(transaction_one.get()); + auto* transaction_two_bytes = static_cast(transaction_two.get()); + transaction_one_bytes[0] = 0x10; + transaction_one_bytes[1] = 0x20; + transaction_two_bytes[0] = 0x30; + transaction_two_bytes[1] = 0x40; + if (argc != 2 || !LoadLibraryW(argv[1])) return 2; - Allocation allocation(64 * 1024); - if (!allocation.get()) return 23; - auto* sentinel_address = static_cast(allocation.get()) + 128; + TopologyAllocation allocation; + if (!allocation.valid()) return 23; + auto* sentinel_address = allocation.get() + allocation.page_size() + 128; std::memcpy(sentinel_address, kSentinel.data(), kSentinel.size()); + MEMORY_BASIC_INFORMATION first_info{}; + MEMORY_BASIC_INFORMATION middle_info{}; + MEMORY_BASIC_INFORMATION final_info{}; + if (VirtualQuery(allocation.get(), &first_info, sizeof(first_info)) != sizeof(first_info) || + VirtualQuery(allocation.get() + allocation.page_size(), &middle_info, + sizeof(middle_info)) != sizeof(middle_info) || + VirtualQuery(allocation.get() + allocation.page_size() * 2, &final_info, + sizeof(final_info)) != sizeof(final_info) || + first_info.AllocationBase != allocation.get() || + middle_info.AllocationBase != allocation.get() || + final_info.AllocationBase != allocation.get() || + first_info.BaseAddress == middle_info.BaseAddress || + middle_info.BaseAddress == final_info.BaseAddress) return 105; const std::wstring pipe = L"\\\\.\\pipe\\CFB27LuaHost.v1." + std::to_wstring(GetCurrentProcessId()); + const std::wstring legacy_pipe = L"\\\\.\\pipe\\CFB27LuaHost." + + std::to_wstring(GetCurrentProcessId()); Json response; if (!Request(pipe, {{"protocol", 1}, {"id", "hello-1"}, {"command", "hello"}, {"params", Json::object()}}, response, true)) return 3; @@ -166,11 +274,204 @@ int wmain(int argc, wchar_t** argv) { const auto capabilities = response["result"]["capabilities"]; if (std::find(capabilities.begin(), capabilities.end(), "evaluate") == capabilities.end()) return 5; if (std::find(capabilities.begin(), capabilities.end(), "telemetry") == capabilities.end()) return 51; + if (std::find(capabilities.begin(), capabilities.end(), + "memoryScanAllocationMetadata") == capabilities.end()) return 106; if (!Request(pipe, {{"protocol", 1}, {"id", "status-1"}, {"command", "status"}, {"params", Json::object()}}, response, false)) return 14; if (!response.value("ok", false) || !response["result"].contains("ready")) return 15; + const Json write_params{ + {"transactionId", "smoke.apply-1"}, + {"operations", Json::array({ + {{"address", FormatAddress(reinterpret_cast(transaction_one_bytes))}, + {"expectedHex", "1020"}, {"replacementHex", "1121"}}, + {{"address", FormatAddress(reinterpret_cast(transaction_two_bytes))}, + {"expectedHex", "3040"}, {"replacementHex", "3141"}}, + })}, + }; + + SetEnvironmentVariableW(L"CFB27_SMOKE_ALLOW_WRITES", nullptr); + if (!Request(pipe, {{"protocol", 1}, {"id", "write-unsupported"}, + {"command", "writeTransaction"}, {"params", write_params}}, + response, false) || !IsError(response, "UNSUPPORTED_BUILD")) { + std::cerr << "writeTransaction RED response: " << response.dump() << '\n'; + return 75; + } + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_ALLOW_WRITES", L"1")) return 76; + + if (!Request(pipe, {{"protocol", 1}, {"id", "write-apply"}, + {"command", "writeTransaction"}, {"params", write_params}}, + response, false)) return 77; + if (!response.value("ok", false) || response["result"].size() != 3 || + response["result"].value("transactionId", "") != "smoke.apply-1" || + response["result"].value("status", "") != "applied_verified" || + response["result"]["operations"].size() != 2 || + response["result"]["operations"][0] != + Json({{"index", 0}, {"applied", true}, {"verified", true}}) || + response["result"]["operations"][1] != + Json({{"index", 1}, {"applied", true}, {"verified", true}}) || + transaction_one_bytes[0] != 0x11 || transaction_one_bytes[1] != 0x21 || + transaction_two_bytes[0] != 0x31 || transaction_two_bytes[1] != 0x41) return 78; + + transaction_one_bytes[0] = 0x10; + transaction_one_bytes[1] = 0x20; + transaction_two_bytes[0] = 0x30; + transaction_two_bytes[1] = 0x40; + Json mismatch_params = write_params; + mismatch_params["transactionId"] = "smoke.mismatch-1"; + mismatch_params["operations"][1]["expectedHex"] = "FFFF"; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-mismatch"}, + {"command", "writeTransaction"}, {"params", mismatch_params}}, + response, false) || !IsError(response, "MEMORY_MISMATCH") || + transaction_one_bytes[0] != 0x10 || transaction_one_bytes[1] != 0x20 || + transaction_two_bytes[0] != 0x30 || transaction_two_bytes[1] != 0x40) return 79; + + Json overlap_params = write_params; + overlap_params["transactionId"] = "smoke.overlap-1"; + overlap_params["operations"][0]["address"] = + FormatAddress(reinterpret_cast(transaction_one_bytes)); + overlap_params["operations"][1]["address"] = + FormatAddress(reinterpret_cast(transaction_one_bytes + 1)); + if (!Request(pipe, {{"protocol", 1}, {"id", "write-overlap"}, + {"command", "writeTransaction"}, {"params", overlap_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 80; + + Json malformed_hex_params = write_params; + malformed_hex_params["transactionId"] = "smoke.malformed-1"; + malformed_hex_params["operations"][0]["replacementHex"] = "1Z"; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-malformed"}, + {"command", "writeTransaction"}, {"params", malformed_hex_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 81; + + Json invalid_address_params = write_params; + invalid_address_params["transactionId"] = "smoke.address-1"; + invalid_address_params["operations"][0]["address"] = "0x01"; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-address"}, + {"command", "writeTransaction"}, {"params", invalid_address_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 82; + + Json extra_params = write_params; + extra_params["unexpected"] = true; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-extra"}, + {"command", "writeTransaction"}, {"params", extra_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 83; + + Json extra_request{ + {"protocol", 1}, {"id", "write-envelope-extra"}, + {"command", "writeTransaction"}, {"params", write_params}, + {"unexpected", true}, + }; + if (!Request(pipe, extra_request, response, false) || + !IsError(response, "INVALID_REQUEST")) { + std::cerr << "extra request key RED response: " << response.dump() << '\n'; + return 85; + } + + Json extra_operation_params = write_params; + extra_operation_params["transactionId"] = "smoke.operation-extra-1"; + extra_operation_params["operations"][0]["unexpected"] = true; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-operation-extra"}, + {"command", "writeTransaction"}, + {"params", extra_operation_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 86; + + Json lowercase_hex_params = write_params; + lowercase_hex_params["transactionId"] = "smoke.lowercase-1"; + lowercase_hex_params["operations"][0]["replacementHex"] = "aa21"; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-lowercase"}, + {"command", "writeTransaction"}, + {"params", lowercase_hex_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 87; + + for (const auto& invalid_transaction_id : + std::vector{"", std::string(65, 'A')}) { + Json invalid_id_params = write_params; + invalid_id_params["transactionId"] = invalid_transaction_id; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-transaction-id"}, + {"command", "writeTransaction"}, + {"params", invalid_id_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 88; + } + + if (std::find(capabilities.begin(), capabilities.end(), "memoryWriteTransaction") == + capabilities.end()) return 74; + if (!Request(pipe, {{"protocol", 1}, {"id", "status-writes"}, + {"command", "status"}, {"params", Json::object()}}, + response, false) || !response.value("ok", false) || + response["result"].value("sessionWritesDisabled", true)) return 84; + + const std::string smoke_lua_write = + "assert(cfb.write_u8(" + + std::to_string(reinterpret_cast(transaction_one_bytes)) + + ", 16, 17)); assert(cfb.write_u8(" + + std::to_string(reinterpret_cast(transaction_one_bytes)) + + ", 17, 16))"; + if (!Request(pipe, {{"protocol", 1}, {"id", "lua-write-smoke-gate"}, + {"command", "evaluate"}, + {"params", {{"source", smoke_lua_write}}}}, + response, false) || !response.value("ok", false) || + transaction_one_bytes[0] != 0x10) { + std::cerr << "Lua smoke write gate RED response: " << response.dump() << '\n'; + return 94; + } + + const std::string lua_expected_mismatch = + "cfb.write_u8(" + + std::to_string(reinterpret_cast(transaction_one_bytes)) + + ", 255, 17)"; + if (!Request(pipe, {{"protocol", 1}, {"id", "lua-write-error-unlock"}, + {"command", "evaluate"}, + {"params", {{"source", lua_expected_mismatch}}}}, + response, false) || !IsError(response, "SCRIPT_ERROR")) return 97; + if (!BoundedRequest(pipe, {{"protocol", 1}, {"id", "write-after-lua-error"}, + {"command", "writeTransaction"}, + {"params", mismatch_params}}, + response) || !IsError(response, "MEMORY_MISMATCH")) { + std::cerr << "Lua write error mutex RED: subsequent transaction timed out\n"; + return 97; + } + + Json inaccessible_params = write_params; + inaccessible_params["transactionId"] = "smoke.access-denied-1"; + inaccessible_params["operations"].erase(inaccessible_params["operations"].begin() + 1); + inaccessible_params["operations"][0]["address"] = "0x1"; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-access-denied"}, + {"command", "writeTransaction"}, + {"params", inaccessible_params}}, + response, false) || !IsError(response, "MEMORY_ACCESS_DENIED") || + !response["error"].value("details", Json::object()).empty()) return 98; + + Json limit_params = write_params; + limit_params["transactionId"] = "smoke.limit-1"; + limit_params["operations"] = Json::array(); + for (std::size_t index = 0; index <= 32; ++index) { + limit_params["operations"].push_back(write_params["operations"][0]); + } + if (!Request(pipe, {{"protocol", 1}, {"id", "write-limit"}, + {"command", "writeTransaction"}, {"params", limit_params}}, + response, false) || !IsError(response, "TRANSACTION_LIMIT_EXCEEDED") || + response["error"].value("message", "") != + "Transaction exceeds an operation or byte limit" || + !response["error"].value("details", Json::object()).empty()) return 99; + + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_APPLY_FAILURE", L"1")) return 100; + Json apply_failure_params = write_params; + apply_failure_params["transactionId"] = "smoke.apply-failure-1"; + apply_failure_params["operations"].erase( + apply_failure_params["operations"].begin() + 1); + if (!Request(pipe, {{"protocol", 1}, {"id", "write-apply-failure"}, + {"command", "writeTransaction"}, + {"params", apply_failure_params}}, + response, false) || !IsError(response, "TRANSACTION_APPLY_FAILED") || + response["error"]["details"].value("transactionId", "") != + "smoke.apply-failure-1" || + response["error"]["details"].value("status", "") != "rolled_back_verified" || + response["error"]["details"]["operations"] != + Json::array({{{"index", 0}, {"applied", false}, {"verified", false}}}) || + transaction_one_bytes[0] != 0x10 || transaction_one_bytes[1] != 0x20) return 100; + SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_APPLY_FAILURE", nullptr); + const Json invalid_request = Json::parse( R"({"protocol":18446744073709551615,"id":"bad-1","command":"hello","params":{}})"); if (!Request(pipe, invalid_request, response, false)) return 12; @@ -243,6 +544,37 @@ int wmain(int argc, wchar_t** argv) { match.value("contextHex", "") != std::string("00000000") + kSentinelHex + "00000000") return 30; allowed_scan_params.erase("cursor"); + Json false_metadata_params = allowed_scan_params; + false_metadata_params["cursor"] = FormatAddress( + reinterpret_cast(allocation.get())); + false_metadata_params["includeAllocationMetadata"] = false; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-allocation-false"}, + {"command", "scanMemory"}, {"params", false_metadata_params}}, + response, false) || !response.value("ok", false) || + response["result"]["matches"].size() != 1 || + response["result"]["matches"][0].size() != 6) return 107; + + Json metadata_params = false_metadata_params; + metadata_params["includeAllocationMetadata"] = true; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-allocation-true"}, + {"command", "scanMemory"}, {"params", metadata_params}}, + response, false) || !response.value("ok", false) || + response["result"]["matches"].size() != 1) return 108; + const auto& allocation_match = response["result"]["matches"][0]; + if (allocation_match.size() != 10 || + allocation_match.value("allocationBase", "") != + FormatAddress(reinterpret_cast(allocation.get())) || + allocation_match.value("allocationSize", 0ull) != allocation.page_size() * 3 || + allocation_match.value("allocationProtect", 0u) != PAGE_READWRITE || + allocation_match.value("offsetInAllocation", 0ull) != + allocation.page_size() + 128) return 109; + + Json invalid_metadata_params = allowed_scan_params; + invalid_metadata_params["includeAllocationMetadata"] = 1; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-allocation-invalid"}, + {"command", "scanMemory"}, {"params", invalid_metadata_params}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 110; + const auto address = FormatAddress(reinterpret_cast(sentinel_address)); const Json read_params{ {"allowUnsupportedBuild", true}, @@ -264,11 +596,22 @@ int wmain(int argc, wchar_t** argv) { response["result"]["ranges"][0].value("bytesHex", "") != kSentinelHex) return 32; Json invalid_params = allowed_scan_params; + invalid_params["patternHex"] = ""; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-empty-pattern"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 104; + invalid_params = allowed_scan_params; invalid_params["patternHex"] = "CFB27A1Z"; if (!Request(pipe, {{"protocol", 1}, {"id", "scan-bad-hex"}, {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || !IsError(response, "INVALID_REQUEST")) return 33; invalid_params = allowed_scan_params; + invalid_params["patternHex"] = std::string((4096 + 1) * 2, 'F'); + invalid_params["maskHex"] = std::string((4096 + 1) * 2, 'F'); + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-hostile-oversized-pattern"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 111; + invalid_params = allowed_scan_params; invalid_params["patternHex"] = "cfb27a1100a1b2c3d4e5f60718293a4b"; if (!Request(pipe, {{"protocol", 1}, {"id", "scan-lower-hex"}, {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || @@ -375,7 +718,7 @@ int wmain(int argc, wchar_t** argv) { !IsError(response, "MEMORY_ACCESS_DENIED") || !response["error"].value("details", Json::object()).empty()) return 45; - std::memcpy(static_cast(allocation.get()) + 256, + std::memcpy(sentinel_address + 128, kSentinel.data(), kSentinel.size()); Json crowded_scan_params = allowed_scan_params; crowded_scan_params["maxMatches"] = 1; @@ -383,7 +726,7 @@ int wmain(int argc, wchar_t** argv) { {"command", "scanMemory"}, {"params", crowded_scan_params}}, response, false) || !IsError(response, "TOO_MANY_MATCHES") || !response["error"].value("details", Json::object()).empty()) return 46; - SecureZeroMemory(static_cast(allocation.get()) + 256, kSentinel.size()); + SecureZeroMemory(sentinel_address + 128, kSentinel.size()); if (!Request(pipe, {{"protocol", 1}, {"id", "emit-unregistered"}, {"command", "evaluate"}, @@ -474,6 +817,77 @@ int wmain(int argc, wchar_t** argv) { event.value("payload", Json::object()).value("message", "") == "event-proof") ++proof_count; } if (proof_count != 1) return 22; + + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_ROLLBACK_UNVERIFIED", L"1")) + return 89; + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_HOLD_ROLLBACK", L"1")) return 95; + Json rollback_params = write_params; + rollback_params["transactionId"] = "smoke.rollback-unverified-1"; + rollback_params["operations"].erase(rollback_params["operations"].begin() + 1); + Json rollback_response; + bool rollback_request_ok = false; + const auto rollback_started = std::chrono::steady_clock::now(); + std::thread rollback_thread([&] { + rollback_request_ok = Request( + pipe, {{"protocol", 1}, {"id", "write-rollback-unverified"}, + {"command", "writeTransaction"}, {"params", rollback_params}}, + rollback_response, false); + }); + Sleep(100); + const std::string concurrent_lua = + "cfb.write_u8(" + + std::to_string(reinterpret_cast(transaction_one_bytes)) + + ", 17, 85)"; + Json concurrent_lua_response; + const bool concurrent_lua_ok = + LegacyEvaluate(legacy_pipe, concurrent_lua, concurrent_lua_response); + rollback_thread.join(); + const auto rollback_elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - rollback_started); + if (rollback_elapsed.count() < 400) { + std::cerr << "held rollback RED elapsedMs=" << rollback_elapsed.count() << '\n'; + return 95; + } + if (!rollback_request_ok || + !IsError(rollback_response, "ROLLBACK_VERIFICATION_FAILED") || + rollback_response["error"]["details"].value("transactionId", "") != + "smoke.rollback-unverified-1" || + rollback_response["error"]["details"].value("status", "") != + "rollback_unverified" || + transaction_one_bytes[0] != 0x10 || transaction_one_bytes[1] != 0x20) { + response = rollback_response; + std::cerr << "rollback injection RED response: " << response.dump() << '\n'; + return 90; + } + if (!concurrent_lua_ok || concurrent_lua_response.value("ok", true) || + concurrent_lua_response.value("result", "").find("session writes are disabled") == + std::string::npos) { + std::cerr << "atomic lockdown RED response: " << concurrent_lua_response.dump() << '\n'; + return 96; + } + SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_ROLLBACK_UNVERIFIED", nullptr); + SetEnvironmentVariableW(L"CFB27_SMOKE_HOLD_ROLLBACK", nullptr); + + if (!Request(pipe, {{"protocol", 1}, {"id", "status-lockdown"}, + {"command", "status"}, {"params", Json::object()}}, + response, false) || !response.value("ok", false) || + !response["result"].value("sessionWritesDisabled", false) || + response["result"].value("writesAllowed", true)) return 91; + if (!Request(pipe, {{"protocol", 1}, {"id", "write-after-lockdown"}, + {"command", "writeTransaction"}, {"params", write_params}}, + response, false) || !IsError(response, "SESSION_WRITES_DISABLED")) return 92; + + const std::string lockdown_lua = + "cfb.write_u8(" + + std::to_string(reinterpret_cast(transaction_one_bytes)) + + ", 16, 17)"; + if (!Request(pipe, {{"protocol", 1}, {"id", "lua-write-after-lockdown"}, + {"command", "evaluate"}, + {"params", {{"source", lockdown_lua}}}}, + response, false) || !IsError(response, "SCRIPT_ERROR") || + response["error"].value("message", "").find("session writes are disabled") == + std::string::npos || + transaction_one_bytes[0] != 0x10) return 93; std::cout << "protocol smoke passed\n"; return 0; } diff --git a/native/smoke/startup_host_smoke.cpp b/native/smoke/startup_host_smoke.cpp index 4ff6afe..a16743a 100644 --- a/native/smoke/startup_host_smoke.cpp +++ b/native/smoke/startup_host_smoke.cpp @@ -1,17 +1,349 @@ #include +#include +#include +#include +#include +#include #include +#include +#include +#include + +using Json = nlohmann::json; + +namespace { + +bool WriteAll(HANDLE pipe, const std::uint8_t* data, std::size_t size) { + while (size) { + DWORD written = 0; + if (!WriteFile(pipe, data, static_cast(size), &written, nullptr) || + written == 0) return false; + data += written; + size -= written; + } + return true; +} + +bool ReadAll(HANDLE pipe, std::uint8_t* data, std::size_t size) { + while (size) { + DWORD read = 0; + if (!ReadFile(pipe, data, static_cast(size), &read, nullptr) || + read == 0) return false; + data += read; + size -= read; + } + return true; +} + +bool Request(const std::wstring& pipe_name, const Json& request, Json& response) { + HANDLE pipe = INVALID_HANDLE_VALUE; + const ULONGLONG deadline = GetTickCount64() + 5000; + while (GetTickCount64() < deadline) { + pipe = CreateFileW(pipe_name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr); + if (pipe != INVALID_HANDLE_VALUE) break; + Sleep(10); + } + if (pipe == INVALID_HANDLE_VALUE) return false; + + const std::string body = request.dump(); + const auto size = static_cast(body.size()); + std::vector frame(4 + body.size()); + frame[0] = static_cast(size); + frame[1] = static_cast(size >> 8); + frame[2] = static_cast(size >> 16); + frame[3] = static_cast(size >> 24); + std::memcpy(frame.data() + 4, body.data(), body.size()); + + std::uint8_t header[4]{}; + bool ok = WriteAll(pipe, frame.data(), frame.size()) && + ReadAll(pipe, header, sizeof(header)); + const std::uint32_t response_size = static_cast(header[0]) | + (static_cast(header[1]) << 8) | + (static_cast(header[2]) << 16) | + (static_cast(header[3]) << 24); + std::vector response_body(response_size); + ok = ok && response_size > 0 && response_size <= 1024 * 1024 && + ReadAll(pipe, response_body.data(), response_body.size()); + CloseHandle(pipe); + if (!ok) return false; + response = Json::parse(response_body.begin(), response_body.end(), nullptr, false); + return !response.is_discarded(); +} + +struct LexicalBrace { + char token{}; + std::size_t position{}; +}; + +std::vector LexicalBraces(const std::string& source, + std::size_t begin, std::size_t end) { + enum class LexicalState { + kCode, + kLineComment, + kBlockComment, + kString, + kChar, + kRawString, + }; + LexicalState state = LexicalState::kCode; + bool escaped = false; + std::string raw_terminator; + std::vector braces; + for (std::size_t index = begin; + index < end && index < source.size(); ++index) { + const char current = source[index]; + const char next = index + 1 < source.size() ? source[index + 1] : '\0'; + switch (state) { + case LexicalState::kCode: + if (current == '/' && next == '/') { + state = LexicalState::kLineComment; + ++index; + } else if (current == '/' && next == '*') { + state = LexicalState::kBlockComment; + ++index; + } else if (current == 'R' && next == '"') { + const auto delimiter_begin = index + 2; + const auto parenthesis = source.find('(', delimiter_begin); + if (parenthesis != std::string::npos && + parenthesis - delimiter_begin <= 16 && + source.substr(delimiter_begin, parenthesis - delimiter_begin) + .find_first_of(" \\\t\v\f\r\n()") == std::string::npos) { + raw_terminator = ")" + + source.substr(delimiter_begin, parenthesis - delimiter_begin) + + "\""; + state = LexicalState::kRawString; + index = parenthesis; + } + } else if (current == '"') { + state = LexicalState::kString; + escaped = false; + } else if (current == '\'') { + state = LexicalState::kChar; + escaped = false; + } else if (current == '{' || current == '}') { + braces.push_back({current, index}); + } + break; + case LexicalState::kLineComment: + if (current == '\n') state = LexicalState::kCode; + break; + case LexicalState::kBlockComment: + if (current == '*' && next == '/') { + state = LexicalState::kCode; + ++index; + } + break; + case LexicalState::kString: + case LexicalState::kChar: + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if ((state == LexicalState::kString && current == '"') || + (state == LexicalState::kChar && current == '\'')) { + state = LexicalState::kCode; + } + break; + case LexicalState::kRawString: + if (source.compare(index, raw_terminator.size(), raw_terminator) == 0) { + index += raw_terminator.size() - 1; + state = LexicalState::kCode; + } + break; + } + } + return braces; +} + +std::optional MatchingBrace(const std::string& source, + std::size_t opening) { + std::size_t depth = 0; + for (const auto& brace : LexicalBraces(source, opening, source.size())) { + if (brace.token == '{') { + ++depth; + } else if (depth > 0 && --depth == 0) { + return brace.position; + } + } + return std::nullopt; +} + +std::optional ContainingScopeOpen(const std::string& source, + std::size_t function_open, + std::size_t position) { + std::vector scopes; + for (const auto& brace : LexicalBraces(source, function_open, position)) { + if (brace.token == '{') { + scopes.push_back(brace.position); + } else if (!scopes.empty()) { + scopes.pop_back(); + } + } + if (scopes.empty()) return std::nullopt; + return scopes.back(); +} + +bool VerifyMatchingBraceFixtures(std::string& error) { + const std::vector fixtures = { + R"({ // } closes only a comment + int value = 1; + })", + R"({ /* } closes only a block comment */ int value = 1; })", + R"({ const char brace = '}'; int value = 1; })", + R"fixture({ const char* braces = "escaped quote: \" }"; int value = 1; })fixture", + R"fixture({ const char* braces = R"tag("})tag"; int value = 1; })fixture", + }; + for (const auto& fixture : fixtures) { + const auto closing = MatchingBrace(fixture, 0); + if (!closing || *closing != fixture.size() - 1) { + error = "brace matcher treated a comment or literal brace as syntax"; + return false; + } + } + return true; +} + +bool VerifyContainingScopeFixtures(std::string& error) { + const std::string fixture = + R"fixture({ { const char* decoy = R"tag({)tag"; LOCK; } })fixture"; + const auto lock_at = fixture.find("LOCK"); + const auto scope_open = ContainingScopeOpen(fixture, 0, lock_at); + if (!scope_open || *scope_open != 2) { + error = "containing scope selected a brace from a non-code token"; + return false; + } + return true; +} + +bool CallsOccurAfter(const std::string& source, std::string_view call, + std::size_t function_begin, std::size_t scope_end, + std::size_t function_end) { + auto found = source.find(call, scope_end); + if (found == std::string::npos || found >= function_end) return false; + for (found = source.find(call, function_begin); + found != std::string::npos && found < function_end; + found = source.find(call, found + call.size())) { + if (found < scope_end) return false; + } + return true; +} + +bool VerifyLuaWriteU8Source(const std::filesystem::path& path, + std::string& error) { + std::ifstream input(path, std::ios::binary); + if (!input) { + error = "could not open Lua host source"; + return false; + } + const std::string source((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + constexpr std::string_view signature = "int LuaWriteU8(lua_State* state)"; + const auto signature_at = source.find(signature); + const auto function_open = source.find('{', signature_at); + if (signature_at == std::string::npos || function_open == std::string::npos) { + error = "LuaWriteU8 definition was not found"; + return false; + } + const auto function_close = MatchingBrace(source, function_open); + constexpr std::string_view lock = + "std::lock_guard write_lock(g_host_write_mutex);"; + const auto lock_at = source.find(lock, function_open); + if (!function_close || lock_at == std::string::npos || lock_at >= *function_close) { + error = "LuaWriteU8 write lock was not found"; + return false; + } + const auto lock_scope_open = + ContainingScopeOpen(source, function_open, lock_at); + const auto lock_scope_close = lock_scope_open + ? MatchingBrace(source, *lock_scope_open) + : std::nullopt; + if (!lock_scope_open || *lock_scope_open == function_open || !lock_scope_close || + *lock_scope_close >= *function_close) { + error = "LuaWriteU8 write lock must have a normally exited nested scope"; + return false; + } + + const auto critical = source.substr(lock_at, *lock_scope_close - lock_at); + if (critical.find("lua_") != std::string::npos || + critical.find("luaL_") != std::string::npos) { + error = "LuaWriteU8 write-lock scope contains a Lua API call"; + return false; + } + + std::size_t argument_checks = 0; + for (auto found = source.find("luaL_checkinteger", function_open); + found != std::string::npos && found < *function_close; + found = source.find("luaL_checkinteger", found + 1)) { + ++argument_checks; + if (found > lock_at) { + error = "LuaWriteU8 parses arguments after acquiring the write lock"; + return false; + } + } + if (argument_checks != 3 || + !CallsOccurAfter(source, "luaL_error", function_open, *lock_scope_close, + *function_close) || + !CallsOccurAfter(source, "lua_pushboolean", function_open, *lock_scope_close, + *function_close)) { + error = "LuaWriteU8 Lua error/result calls must follow normal lock-scope exit"; + return false; + } + return true; +} + +} // namespace int wmain(int argc, wchar_t** argv) { - if (argc != 2) { - std::wcerr << L"usage: startup_host_smoke \n"; + std::string matcher_error; + if (!VerifyMatchingBraceFixtures(matcher_error)) { + std::cerr << "startup source matcher RED: " << matcher_error << '\n'; + return 8; + } + if (!VerifyContainingScopeFixtures(matcher_error)) { + std::cerr << "startup scope matcher RED: " << matcher_error << '\n'; + return 9; + } + if (argc < 2 || argc > 3) { + std::wcerr << L"usage: startup_host_smoke [lua_host.cpp]\n"; return 2; } + const std::filesystem::path source_path = + argc == 3 ? std::filesystem::path(argv[2]) + : std::filesystem::path(L"native/host/lua_host.cpp"); + std::string source_error; + if (!VerifyLuaWriteU8Source(source_path, source_error)) { + std::cerr << "LuaWriteU8 source policy RED: " << source_error << '\n'; + return 7; + } + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_ALLOW_WRITES", L"1")) return 3; + if (!SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_ROLLBACK_UNVERIFIED", L"1") || + !SetEnvironmentVariableW(L"CFB27_SMOKE_HOLD_ROLLBACK", L"1") || + !SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_APPLY_FAILURE", L"1")) return 6; HMODULE host = LoadLibraryW(argv[1]); if (!host) { std::wcerr << L"LoadLibrary failed: " << GetLastError() << L'\n'; return 1; } - Sleep(10000); + std::uint8_t bytes[]{0x10, 0x20}; + char address[32]{}; + sprintf_s(address, "0x%llX", + static_cast(reinterpret_cast(bytes))); + const std::wstring pipe = L"\\\\.\\pipe\\CFB27LuaHost.v1." + + std::to_wstring(GetCurrentProcessId()); + Json response; + if (!Request(pipe, {{"protocol", 1}, {"id", "startup-write-gate"}, + {"command", "writeTransaction"}, + {"params", {{"transactionId", "startup.gate-1"}, + {"operations", Json::array({ + {{"address", std::string(address)}, + {"expectedHex", "1020"}, + {"replacementHex", "1121"}}})}}}}, + response)) return 4; + if (response.value("ok", true) || + response["error"].value("code", "") != "UNSUPPORTED_BUILD" || + bytes[0] != 0x10 || bytes[1] != 0x20) return 5; + std::cout << "startup smoke passed; smoke write gate rejected for non-protocol executable\n"; return 0; } diff --git a/package-lock.json b/package-lock.json index 8256069..9ab1632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cfb27-lua-hook-workspace", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cfb27-lua-hook-workspace", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "license": "MIT", "workspaces": [ "packages/sdk", @@ -26,10 +26,10 @@ }, "packages/cli": { "name": "cfb27-lua-hook", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "license": "MIT", "dependencies": { - "@cfb27/lua-hook": "0.2.0-dev.1" + "@cfb27/lua-hook": "0.2.0-dev.2" }, "bin": { "cfb27lua": "bin/cfb27lua.cjs" @@ -40,7 +40,7 @@ }, "packages/sdk": { "name": "@cfb27/lua-hook", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index aa94da6..c72e4b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cfb27-lua-hook-workspace", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "private": true, "description": "Offline CFB27 Lua hook, scripting SDK, and MMC startup tooling for PC.", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4e191a2..d27558b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "cfb27-lua-hook", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "description": "Developer CLI for the offline CFB27 Lua hook.", "type": "commonjs", "license": "MIT", @@ -15,6 +15,6 @@ "src" ], "dependencies": { - "@cfb27/lua-hook": "0.2.0-dev.1" + "@cfb27/lua-hook": "0.2.0-dev.2" } } diff --git a/packages/cli/src/args.cjs b/packages/cli/src/args.cjs index c357306..4c13d41 100644 --- a/packages/cli/src/args.cjs +++ b/packages/cli/src/args.cjs @@ -45,6 +45,8 @@ function parseArgs(argv) { context: undefined, ranges: [], allowUnsupportedBuild: false, + includeAllocationMetadata: false, + allowExternalFile: false, }; const seen = new Set(); const values = new Map([ @@ -74,12 +76,18 @@ function parseArgs(argv) { help = true; continue; } - if (token === '--json' || token === '--follow' || token === '--allow-unsupported-build') { + if (token === '--json' || token === '--follow' || token === '--allow-unsupported-build' || + token === '--include-allocation-metadata' || + token === '--allow-external-file') { if (seen.has(token)) throw usageError(`Duplicate option: ${token}`); seen.add(token); if (token === '--json') json = true; else if (token === '--follow') options.follow = true; - else options.allowUnsupportedBuild = true; + else if (token === '--allow-unsupported-build') options.allowUnsupportedBuild = true; + else if (token === '--include-allocation-metadata') { + options.includeAllocationMetadata = true; + } + else options.allowExternalFile = true; continue; } if (token === '--range') { @@ -102,6 +110,13 @@ function parseArgs(argv) { index += 1; continue; } + if (token === '-') { + if (command === 'memory' && positionals.length === 1 && positionals[0] === 'transact') { + positionals.push(token); + continue; + } + throw usageError('Unknown option: -'); + } if (token.startsWith('-')) throw usageError(`Unknown option: ${token}`); if (!command) command = token; else positionals.push(token); diff --git a/packages/cli/src/main.cjs b/packages/cli/src/main.cjs index de82404..127d215 100644 --- a/packages/cli/src/main.cjs +++ b/packages/cli/src/main.cjs @@ -1,9 +1,25 @@ 'use strict'; +const fs = require('node:fs/promises'); const path = require('node:path'); const { parseArgs, usageError } = require('./args.cjs'); const { printSuccess, printError } = require('./output.cjs'); +const TRANSACTION_ERROR_MESSAGES = Object.freeze({ + INVALID_REQUEST: 'Transaction request was rejected', + UNSUPPORTED_BUILD: 'Memory writes require the supported game build', + MEMORY_ACCESS_DENIED: 'Transaction memory is not available for writing', + MEMORY_MISMATCH: 'Live memory does not match the transaction preflight', + TRANSACTION_LIMIT_EXCEEDED: 'Transaction exceeds an operation or byte limit', + TRANSACTION_APPLY_FAILED: 'Transaction failed and was rolled back', + ROLLBACK_VERIFICATION_FAILED: 'Transaction rollback could not be verified', + SESSION_WRITES_DISABLED: 'Writes are disabled for this host session', + HOST_NOT_READY: 'Could not connect to the Lua host', + PIPE_TIMEOUT: 'Lua host transaction request timed out', + PROTOCOL_MISMATCH: 'Host protocol version does not match', + INVALID_RESPONSE: 'Host returned an invalid writeTransaction response', +}); + const HELP = `cfb27lua [options] Commands: @@ -17,6 +33,8 @@ Commands: events Read host events after a cursor memory scan Scan bounded private readable memory (diagnostic) memory read Read bounded canonical address ranges (diagnostic) + memory transact + Apply one guarded transaction from a JSON request file telemetry register Register structured telemetry type names @@ -35,6 +53,9 @@ Options: --range Canonical read range; may be repeated --allow-unsupported-build Explicitly allow unsupported-build diagnostics + --include-allocation-metadata + Include session-only allocation topology in scan matches + --allow-external-file Allow a transaction JSON file outside the current directory -h, --help Show this help`; const defaultIo = { @@ -70,6 +91,7 @@ function requireMemoryScanOptions(options) { contextAfter: options.context, maxPages: options.maxPages || 4096, ...(options.allowUnsupportedBuild ? { allowUnsupportedBuild: true } : {}), + ...(options.includeAllocationMetadata ? { includeAllocationMetadata: true } : {}), }; } @@ -77,6 +99,7 @@ function rejectMisplacedDeveloperOptions(command, positionals, options) { const operation = command === 'memory' ? positionals[0] : undefined; const scanOptions = [ options.pattern, options.mask, options.maxMatches, options.maxPages, options.context, + options.includeAllocationMetadata || undefined, ]; if (command !== 'memory' && (scanOptions.some((value) => value !== undefined) || options.ranges.length || options.allowUnsupportedBuild)) { @@ -88,6 +111,55 @@ function rejectMisplacedDeveloperOptions(command, positionals, options) { if (operation === 'read' && scanOptions.some((value) => value !== undefined)) { throw usageError('Scan options are not valid for memory read'); } + if (operation === 'transact') { + const hasUnrelatedOption = Object.entries(options).some(([key, value]) => { + if (key === 'allowExternalFile') return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'boolean') return value; + return value !== undefined; + }); + if (hasUnrelatedOption) { + throw usageError('This option is not valid for memory transact'); + } + } + if (operation !== 'transact' && options.allowExternalFile) { + throw usageError('--allow-external-file is only valid for memory transact'); + } +} + +async function readTransactionRequest(file, { fileSystem, cwd, allowExternalFile }) { + if (file === '-') throw usageError('memory transact requires a JSON file and refuses stdin'); + if (path.extname(file).toLowerCase() !== '.json') { + throw usageError('memory transact requires a .json file'); + } + let resolvedCwd; + try { + resolvedCwd = await fileSystem.realpath(path.resolve(cwd)); + } catch (error) { + throw usageError(`Could not resolve current working directory: ${error.message}`); + } + let resolved; + try { + resolved = await fileSystem.realpath(path.resolve(cwd, file)); + } catch (error) { + throw usageError(`Could not resolve transaction JSON file: ${error.message}`); + } + const relative = path.relative(resolvedCwd, resolved); + if (!allowExternalFile && (relative === '..' || relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative))) { + throw usageError('Transaction file is outside the current working directory; pass --allow-external-file to allow it'); + } + let source; + try { + source = await fileSystem.readFile(resolved, 'utf8'); + } catch (error) { + throw usageError(`Could not read transaction JSON file: ${error.message}`); + } + try { + return JSON.parse(source); + } catch { + throw usageError('Transaction file must contain valid JSON'); + } } function memoryHumanLines(command, result) { @@ -107,10 +179,21 @@ function memoryHumanLines(command, result) { function printCommandSuccess(io, command, result, json) { if (json) { + if (command === 'memory transact') { + io.out(JSON.stringify(result)); + return; + } printSuccess(io, command, result, true); return; } if (command.startsWith('memory ')) { + if (command === 'memory transact') { + const applied = result.operations.filter((operation) => operation.applied).length; + const verified = result.operations.filter((operation) => operation.verified).length; + io.out(`Transaction ${result.transactionId}: ${result.status}`); + io.out(`${result.operations.length} ${result.operations.length === 1 ? 'operation' : 'operations'}, ${applied} applied, ${verified} verified`); + return; + } for (const line of memoryHumanLines(command, result)) io.out(line); return; } @@ -123,7 +206,21 @@ function printCommandSuccess(io, command, result, json) { printSuccess(io, command, result, false); } -async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = {}) { +function sanitizeTransactionError(error) { + if (error?.code === 'USAGE') return error; + const code = typeof error?.code === 'string' && + Object.hasOwn(TRANSACTION_ERROR_MESSAGES, error.code) + ? error.code + : 'INVALID_RESPONSE'; + return Object.assign(new Error(TRANSACTION_ERROR_MESSAGES[code]), { code }); +} + +async function main(argv, { + sdk = require('@cfb27/lua-hook'), + io = defaultIo, + fileSystem = fs, + cwd = process.cwd(), +} = {}) { let parsed = { json: false }; try { parsed = parseArgs(argv); @@ -209,8 +306,19 @@ async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = if (options.allowUnsupportedBuild) readOptions.allowUnsupportedBuild = true; const game = await sdk.discoverGame(); result = await sdk.createClient({ pid: game.pid }).readMemory(readOptions); + } else if (operation === 'transact') { + if (extra.length !== 1) { + throw usageError('memory transact requires exactly one JSON file'); + } + const transaction = await readTransactionRequest(extra[0], { + fileSystem, + cwd, + allowExternalFile: options.allowExternalFile, + }); + const game = await sdk.discoverGame(); + result = await sdk.createClient({ pid: game.pid }).writeTransaction(transaction); } else { - throw usageError('memory requires scan or read'); + throw usageError('memory requires scan, read, or transact'); } } else if (command === 'telemetry') { const [operation, ...types] = positionals; @@ -228,8 +336,11 @@ async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = printCommandSuccess(io, displayCommand, result, json); return 0; } catch (error) { - printError(io, error, parsed.json === true); - return exitCodeFor(error); + const safeError = parsed.command === 'memory' && parsed.positionals?.[0] === 'transact' + ? sanitizeTransactionError(error) + : error; + printError(io, safeError, parsed.json === true); + return exitCodeFor(safeError); } } diff --git a/packages/cli/test/main.test.cjs b/packages/cli/test/main.test.cjs index f841b1b..780fdd3 100644 --- a/packages/cli/test/main.test.cjs +++ b/packages/cli/test/main.test.cjs @@ -2,6 +2,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); const path = require('node:path'); const { main } = require('../src/main.cjs'); @@ -17,6 +19,13 @@ function memoryIo(env = {}) { }; } +function memoryFileSystem(readFile) { + return { + readFile, + realpath: async (value) => path.resolve(value), + }; +} + test('status --json prints one stable JSON object', async () => { const { io, output } = memoryIo(); const sdk = { @@ -166,6 +175,10 @@ test('memory scan parses diagnostic options and preserves the validated SDK resu protection: 4, contextAddress: '0x7FF612340060', contextHex: `${'00'.repeat(32)}CFB27A1100A1B2C3D4E5F60718293A4B${'00'.repeat(32)}`, + allocationBase: '0x7FF612300000', + allocationSize: 4194304, + allocationProtect: 4, + offsetInAllocation: 262272, }], }; const calls = []; @@ -188,6 +201,7 @@ test('memory scan parses diagnostic options and preserves the validated SDK resu '--context', '32', '--max-pages', '3', '--allow-unsupported-build', + '--include-allocation-metadata', '--json', ], { sdk, io }), 0); assert.deepEqual(calls, [ @@ -200,6 +214,7 @@ test('memory scan parses diagnostic options and preserves the validated SDK resu contextAfter: 32, maxPages: 3, allowUnsupportedBuild: true, + includeAllocationMetadata: true, }], ]); assert.deepEqual(JSON.parse(output.stdout), { @@ -422,6 +437,260 @@ test('allow-unsupported-build is explicit and only valid for memory diagnostics' assert.match(invalid.output.stderr, /only valid for memory/); }); +test('memory transact reads one JSON request file and preserves the validated SDK result', async () => { + const request = { + transactionId: 'recruiting.influence-proof-1', + operations: [{ + address: '0x7FF612340000', + expectedHex: '1020', + replacementHex: '1121', + }], + }; + const result = { + transactionId: request.transactionId, + status: 'applied_verified', + operations: [{ index: 0, applied: true, verified: true }], + }; + const reads = []; + const calls = []; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + writeTransaction: async (value) => { calls.push(value); return result; }, + }), + }; + const fileSystem = { + readFile: async (file, encoding) => { + reads.push([file, encoding]); + return JSON.stringify(request); + }, + realpath: async (value) => path.resolve(value), + }; + + assert.equal(await main([ + 'memory', 'transact', 'proof-transaction.json', '--json', + ], { sdk, io, fileSystem, cwd: 'C:\\workspace' }), 0); + assert.deepEqual(reads, [[path.resolve('C:\\workspace', 'proof-transaction.json'), 'utf8']]); + assert.deepEqual(calls, [request]); + assert.deepEqual(JSON.parse(output.stdout), result); +}); + +test('memory transact human output contains only transaction identity, status, and counts', async () => { + const request = { + transactionId: 'recruiting.influence-proof-1', + operations: [{ + address: '0x7FF612340000', + expectedHex: '1020', + replacementHex: '1121', + }], + }; + const result = { + transactionId: request.transactionId, + status: 'applied_verified', + operations: [{ index: 0, applied: true, verified: true }], + }; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ writeTransaction: async () => result }), + }; + assert.equal(await main(['memory', 'transact', 'proof.json'], { + sdk, + io, + fileSystem: memoryFileSystem(async () => JSON.stringify(request)), + cwd: 'C:\\workspace', + }), 0); + assert.match(output.stdout, /recruiting\.influence-proof-1/); + assert.match(output.stdout, /applied_verified/); + assert.match(output.stdout, /1 operation/); + assert.match(output.stdout, /1 applied/); + assert.match(output.stdout, /1 verified/); + for (const secret of ['0x7FF612340000', '1020', '1121', 'address', 'expectedHex', 'replacementHex']) { + assert.equal(output.stdout.includes(secret), false, `human output leaked ${secret}`); + } +}); + +test('memory transact refuses stdin, non-JSON files, and external absolute paths by default', async () => { + const cases = [ + [['memory', 'transact', '-'], /JSON file/], + [['memory', 'transact', 'proof.txt'], /\.json/], + [['memory', 'transact', 'C:\\outside\\proof.json'], /outside the current working directory/], + [['memory', 'transact'], /exactly one JSON file/], + [['memory', 'transact', 'one.json', 'two.json'], /exactly one JSON file/], + ]; + for (const [argv, pattern] of cases) { + let reads = 0; + const { io, output } = memoryIo(); + assert.equal(await main(argv, { + sdk: {}, + io, + fileSystem: memoryFileSystem(async () => { reads += 1; return '{}'; }), + cwd: 'C:\\workspace', + }), 2, argv.join(' ')); + assert.match(output.stderr, pattern, argv.join(' ')); + assert.equal(reads, 0, argv.join(' ')); + } +}); + +test('bare dash remains an invalid option outside the transaction file slot', async () => { + const cases = [ + ['run', '-'], + ['eval', '-'], + ['telemetry', 'register', '-'], + ]; + for (const argv of cases) { + const { io, output } = memoryIo(); + assert.equal(await main(argv, { sdk: {}, io }), 2, argv.join(' ')); + assert.match(output.stderr, /Unknown option: -/, argv.join(' ')); + } +}); + +test('memory transact allows an external absolute JSON file only with the explicit flag', async () => { + const request = { + transactionId: 'proof.external-1', + operations: [{ address: '0x1000', expectedHex: '00', replacementHex: '01' }], + }; + const result = { + transactionId: request.transactionId, + status: 'applied_verified', + operations: [{ index: 0, applied: true, verified: true }], + }; + const reads = []; + const calls = []; + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + writeTransaction: async (value) => { calls.push(value); return result; }, + }), + }; + assert.equal(await main([ + 'memory', 'transact', 'C:\\outside\\proof.json', '--allow-external-file', '--json', + ], { + sdk, + io: memoryIo().io, + fileSystem: memoryFileSystem(async (file) => { reads.push(file); return JSON.stringify(request); }), + cwd: 'C:\\workspace', + }), 0); + assert.deepEqual(reads, ['C:\\outside\\proof.json']); + assert.deepEqual(calls, [request]); +}); + +test('memory transact resolves junction targets before enforcing CWD containment', async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'cfb27-cli-cwd-')); + const external = await fs.mkdtemp(path.join(os.tmpdir(), 'cfb27-cli-external-')); + t.after(async () => { + await fs.rm(cwd, { recursive: true, force: true }); + await fs.rm(external, { recursive: true, force: true }); + }); + const request = { + transactionId: 'proof.link-1', + operations: [{ address: '0x1000', expectedHex: '00', replacementHex: '01' }], + }; + const result = { + transactionId: request.transactionId, + status: 'applied_verified', + operations: [{ index: 0, applied: true, verified: true }], + }; + await fs.writeFile(path.join(external, 'proof.json'), JSON.stringify(request)); + await fs.symlink(external, path.join(cwd, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + + let calls = 0; + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + writeTransaction: async () => { calls += 1; return result; }, + }), + }; + const refused = memoryIo(); + assert.equal(await main(['memory', 'transact', 'linked/proof.json'], { + sdk, io: refused.io, cwd, + }), 2); + assert.match(refused.output.stderr, /outside the current working directory/); + assert.equal(calls, 0); + + const allowed = memoryIo(); + assert.equal(await main([ + 'memory', 'transact', 'linked/proof.json', '--allow-external-file', '--json', + ], { sdk, io: allowed.io, cwd }), 0); + assert.deepEqual(JSON.parse(allowed.output.stdout), result); + assert.equal(calls, 1); +}); + +test('allow-external-file is only valid for memory transact', async () => { + const { io, output } = memoryIo(); + assert.equal(await main(['status', '--allow-external-file'], { sdk: {}, io }), 2); + assert.match(output.stderr, /only valid for memory transact/); +}); + +test('memory transact rejects diagnostic controls and accepts only the JSON file request', async () => { + const cases = [ + ['--pattern', '0011223344556677'], + ['--mask', 'FFFFFFFFFFFFFFFF'], + ['--max-matches', '1'], + ['--max-pages', '1'], + ['--context', '0'], + ['--range', '0x1000:1'], + ['--allow-unsupported-build'], + ['--follow'], + ['--after', '1'], + ['--game-dir', 'C:\\game'], + ['--mmc-dir', 'C:\\mmc'], + ['--artifacts-dir', 'C:\\artifacts'], + ]; + for (const option of cases) { + let reads = 0; + const { io, output } = memoryIo(); + const argv = ['memory', 'transact', 'proof.json', ...option]; + assert.equal(await main(argv, { + sdk: {}, + io, + fileSystem: memoryFileSystem(async () => { reads += 1; return '{}'; }), + cwd: 'C:\\workspace', + }), 2, argv.join(' ')); + assert.match(output.stderr, /not valid for memory transact/, argv.join(' ')); + assert.equal(reads, 0, argv.join(' ')); + } +}); + +test('memory transact sanitizes hostile errors in human and JSON output', async () => { + const hostile = Object.assign( + new Error('address 0x7FF612340000 expected 1020 actual DEADBEEF'), + { + code: 'MEMORY_MISMATCH', + details: { + address: '0x7FF612340000', + expectedHex: '1020', + actualHex: 'DEADBEEF', + }, + }, + ); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ writeTransaction: async () => { throw hostile; } }), + }; + for (const json of [false, true]) { + const { io, output } = memoryIo(); + const argv = ['memory', 'transact', 'proof.json', ...(json ? ['--json'] : [])]; + assert.notEqual(await main(argv, { + sdk, + io, + fileSystem: memoryFileSystem(async () => JSON.stringify({ + transactionId: 'proof.hostile-1', + operations: [{ address: '0x1000', expectedHex: '00', replacementHex: '01' }], + })), + cwd: 'C:\\workspace', + }), 0); + const combined = `${output.stdout}${output.stderr}`; + assert.equal(combined.includes('0x7FF612340000'), false); + assert.equal(combined.includes('1020'), false); + assert.equal(combined.includes('DEADBEEF'), false); + assert.equal(combined.includes('expectedHex'), false); + assert.equal(combined.includes('actualHex'), false); + assert.match(combined, /MEMORY_MISMATCH/); + } +}); + test('developer-only options are rejected outside their exact diagnostic operation', async () => { const cases = [ ['status', '--pattern', '0011223344556677'], diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 09aa28e..bda2e31 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cfb27/lua-hook", - "version": "0.2.0-dev.1", + "version": "0.2.0-dev.2", "description": "Node SDK for the offline CFB27 Lua hook.", "main": "index.cjs", "type": "commonjs", diff --git a/packages/sdk/src/client.cjs b/packages/sdk/src/client.cjs index 4e4b2e0..3e26eb2 100644 --- a/packages/sdk/src/client.cjs +++ b/packages/sdk/src/client.cjs @@ -23,8 +23,34 @@ const MEMORY_LIMITS = Object.freeze({ const CANONICAL_ADDRESS = /^0x(?:0|[1-9A-F][0-9A-F]{0,15})$/; const UPPER_HEX_BYTES = /^(?:[0-9A-F]{2})+$/; const TELEMETRY_TYPE = /^[a-z][a-z0-9_.-]{0,63}$/; +const TRANSACTION_ID = /^[A-Za-z0-9._-]{1,64}$/; const RESERVED_TELEMETRY_TYPES = new Set(['game_ready', 'tick', 'log']); const PIPE_CONNECT_RETRY_DELAY_MS = 10; +const MAX_UINT64 = 0xFFFFFFFFFFFFFFFFn; +const WRITE_TRANSACTION_ERROR_MESSAGES = Object.freeze({ + INVALID_REQUEST: 'Host rejected the write transaction request', + UNSUPPORTED_BUILD: 'Memory writes require the supported game build', + MEMORY_ACCESS_DENIED: 'Transaction memory is not available for writing', + MEMORY_MISMATCH: 'Live memory does not match the transaction preflight', + TRANSACTION_LIMIT_EXCEEDED: 'Transaction exceeds an operation or byte limit', + TRANSACTION_APPLY_FAILED: 'Transaction failed and was rolled back', + ROLLBACK_VERIFICATION_FAILED: 'Transaction rollback could not be verified', + SESSION_WRITES_DISABLED: 'Writes are disabled for this host session', + HOST_NOT_READY: 'Could not connect to the Lua host', + PIPE_TIMEOUT: 'Lua host transaction request timed out', + PROTOCOL_MISMATCH: 'Host protocol version does not match', + INVALID_RESPONSE: 'Host returned an invalid writeTransaction response', +}); +const HOST_WRITE_TRANSACTION_ERROR_CODES = new Set([ + 'INVALID_REQUEST', + 'UNSUPPORTED_BUILD', + 'MEMORY_ACCESS_DENIED', + 'MEMORY_MISMATCH', + 'TRANSACTION_LIMIT_EXCEEDED', + 'TRANSACTION_APPLY_FAILED', + 'ROLLBACK_VERIFICATION_FAILED', + 'SESSION_WRITES_DISABLED', +]); function invalidRequest(message) { return new Cfb27HookError('INVALID_REQUEST', message); @@ -70,7 +96,7 @@ function cloneUpperHex(value, minimumBytes, maximumBytes, fieldName) { function cloneScanPageOptions(options) { const keys = ['patternHex', 'maskHex', 'maxMatches', 'contextBefore', 'contextAfter', - 'allowUnsupportedBuild', 'cursor']; + 'allowUnsupportedBuild', 'cursor', 'includeAllocationMetadata']; if (!hasOnlyKeys(options, keys)) throw invalidRequest('scanMemory options are invalid'); const patternHex = cloneUpperHex( options.patternHex, @@ -100,6 +126,10 @@ function cloneScanPageOptions(options) { if (Object.hasOwn(options, 'cursor') && !isCanonicalAddress(options.cursor)) { throw invalidRequest('cursor must be a canonical uppercase address'); } + if (Object.hasOwn(options, 'includeAllocationMetadata') && + typeof options.includeAllocationMetadata !== 'boolean') { + throw invalidRequest('includeAllocationMetadata must be a boolean'); + } const clone = { patternHex, @@ -112,12 +142,16 @@ function cloneScanPageOptions(options) { clone.allowUnsupportedBuild = options.allowUnsupportedBuild; } if (Object.hasOwn(options, 'cursor')) clone.cursor = options.cursor; + if (Object.hasOwn(options, 'includeAllocationMetadata')) { + clone.includeAllocationMetadata = options.includeAllocationMetadata; + } return clone; } function cloneAggregateScanOptions(options) { if (!hasOnlyKeys(options, ['patternHex', 'maskHex', 'maxMatches', 'contextBefore', - 'contextAfter', 'allowUnsupportedBuild', 'maxPages']) || Object.hasOwn(options, 'cursor')) { + 'contextAfter', 'allowUnsupportedBuild', 'includeAllocationMetadata', 'maxPages']) || + Object.hasOwn(options, 'cursor')) { throw invalidRequest('scanMemory aggregate options are invalid'); } const maxPages = Object.hasOwn(options, 'maxPages') ? options.maxPages : MEMORY_LIMITS.maxPages; @@ -177,8 +211,11 @@ function validateScanPageResult(result, params) { const maximumContextBytes = params.patternHex.length / 2 + params.contextBefore + params.contextAfter; for (const match of result.matches) { - if (!hasExactKeys(match, ['address', 'regionBase', 'regionSize', 'protection', - 'contextAddress', 'contextHex']) || + const matchKeys = params.includeAllocationMetadata === true + ? ['address', 'regionBase', 'regionSize', 'protection', 'contextAddress', 'contextHex', + 'allocationBase', 'allocationSize', 'allocationProtect', 'offsetInAllocation'] + : ['address', 'regionBase', 'regionSize', 'protection', 'contextAddress', 'contextHex']; + if (!hasExactKeys(match, matchKeys) || !isCanonicalAddress(match.address) || !isCanonicalAddress(match.regionBase) || !isSafeIntegerBetween(match.regionSize, 1, Number.MAX_SAFE_INTEGER) || !isSafeIntegerBetween(match.protection, 0, 0xFFFFFFFF) || @@ -188,7 +225,15 @@ function validateScanPageResult(result, params) { match.contextHex.length / 2, params.patternHex.length / 2, maximumContextBytes, - )) { + ) || + (params.includeAllocationMetadata === true && + (!isCanonicalAddress(match.allocationBase) || + !isSafeIntegerBetween(match.allocationSize, 1, Number.MAX_SAFE_INTEGER) || + !isSafeIntegerBetween(match.allocationProtect, 0, 0xFFFFFFFF) || + !isSafeIntegerBetween(match.offsetInAllocation, 0, + match.allocationSize - 1) || + BigInt(match.address) !== + BigInt(match.allocationBase) + BigInt(match.offsetInAllocation)))) { throw invalidResponse('Host returned an invalid scanMemory match'); } } @@ -219,6 +264,135 @@ function validateReadResult(result, params) { return result; } +function cloneWriteTransactionOptions(options) { + if (!hasExactKeys(options, ['transactionId', 'operations']) || + typeof options.transactionId !== 'string' || + !TRANSACTION_ID.test(options.transactionId) || + !Array.isArray(options.operations) || + options.operations.length < 1 || options.operations.length > 32) { + throw invalidRequest('writeTransaction options are invalid'); + } + + let totalBytes = 0; + const ranges = []; + const operations = options.operations.map((operation) => { + if (!hasExactKeys(operation, ['address', 'expectedHex', 'replacementHex']) || + !isCanonicalAddress(operation.address)) { + throw invalidRequest('writeTransaction operation is invalid'); + } + const expectedHex = cloneUpperHex(operation.expectedHex, 1, 4096, 'expectedHex'); + const replacementHex = cloneUpperHex(operation.replacementHex, 1, 4096, 'replacementHex'); + if (expectedHex.length !== replacementHex.length) { + throw invalidRequest('expectedHex and replacementHex must have equal byte lengths'); + } + const byteLength = expectedHex.length / 2; + if (totalBytes > 65536 - byteLength) { + throw invalidRequest('writeTransaction exceeds the total byte limit'); + } + totalBytes += byteLength; + const start = BigInt(operation.address); + const end = start + BigInt(byteLength); + if (end > MAX_UINT64) { + throw invalidRequest('writeTransaction operation exceeds the address space'); + } + ranges.push({ start, end }); + return Object.freeze({ address: operation.address, expectedHex, replacementHex }); + }); + + ranges.sort((left, right) => left.start < right.start ? -1 : left.start > right.start ? 1 : 0); + for (let index = 1; index < ranges.length; index += 1) { + if (ranges[index].start < ranges[index - 1].end) { + throw invalidRequest('writeTransaction operations must not overlap'); + } + } + return Object.freeze({ + transactionId: options.transactionId, + operations: Object.freeze(operations), + }); +} + +function validateWriteTransactionResult(result, params) { + if (!hasExactKeys(result, ['transactionId', 'status', 'operations']) || + result.transactionId !== params.transactionId || + result.status !== 'applied_verified' || + !Array.isArray(result.operations) || + result.operations.length !== params.operations.length) { + throw invalidResponse('Host returned an invalid writeTransaction result'); + } + const operations = result.operations.map((operation, index) => { + if (!hasExactKeys(operation, ['index', 'applied', 'verified']) || + operation.index !== index || operation.applied !== true || operation.verified !== true) { + throw invalidResponse('Host returned an invalid writeTransaction operation result'); + } + return Object.freeze({ index, applied: true, verified: true }); + }); + return Object.freeze({ + transactionId: result.transactionId, + status: result.status, + operations: Object.freeze(operations), + }); +} + +function validateWriteTransactionFailureDetails(details, params, code) { + const expectedStatus = code === 'TRANSACTION_APPLY_FAILED' + ? 'rolled_back_verified' + : 'rollback_unverified'; + if (!hasExactKeys(details, ['transactionId', 'status', 'operations']) || + details.transactionId !== params.transactionId || + details.status !== expectedStatus || + !Array.isArray(details.operations) || + details.operations.length !== params.operations.length) { + throw invalidResponse('Host returned malformed writeTransaction error details'); + } + for (let index = 0; index < details.operations.length; index += 1) { + const operation = details.operations[index]; + if (!hasExactKeys(operation, ['index', 'applied', 'verified']) || + operation.index !== index || typeof operation.applied !== 'boolean' || + typeof operation.verified !== 'boolean') { + throw invalidResponse('Host returned malformed writeTransaction error operation details'); + } + } +} + +function validateWriteTransactionErrorResponse(response, expectedId, params) { + if (!hasExactKeys(response, ['protocol', 'id', 'ok', 'error']) || + response.protocol !== 1 || response.id !== expectedId || response.ok !== false || + !isObject(response.error)) { + throw invalidResponse('Host returned a malformed writeTransaction error response'); + } + const { error } = response; + if (typeof error.code !== 'string' || !HOST_WRITE_TRANSACTION_ERROR_CODES.has(error.code) || + typeof error.message !== 'string') { + throw invalidResponse('Host returned a malformed writeTransaction error'); + } + const hasFailureDetails = error.code === 'TRANSACTION_APPLY_FAILED' || + error.code === 'ROLLBACK_VERIFICATION_FAILED'; + if (!hasExactKeys(error, ['code', 'message', 'details'])) { + throw invalidResponse('Host returned unexpected writeTransaction error properties'); + } + if (hasFailureDetails) { + validateWriteTransactionFailureDetails(error.details, params, error.code); + } else if (!hasExactKeys(error.details, [])) { + throw invalidResponse('Host returned nonempty writeTransaction error details'); + } + return error.code; +} + +function validateWriteTransactionSuccessResponse(response, expectedId) { + if (!hasExactKeys(response, ['protocol', 'id', 'ok', 'result']) || + response.protocol !== 1 || response.id !== expectedId || response.ok !== true) { + throw invalidResponse('Host returned a malformed writeTransaction success response'); + } +} + +function sanitizeWriteTransactionError(error) { + const code = typeof error?.code === 'string' && + Object.hasOwn(WRITE_TRANSACTION_ERROR_MESSAGES, error.code) + ? error.code + : 'INVALID_RESPONSE'; + return new Cfb27HookError(code, WRITE_TRANSACTION_ERROR_MESSAGES[code]); +} + function cloneTelemetryTypes(types) { if (!Array.isArray(types) || types.length < 1 || types.length > 16) { throw invalidRequest('registerTelemetryTypes requires 1 to 16 type names'); @@ -251,7 +425,10 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { } const resolvedPipeName = pipeName || `\\\\.\\pipe\\CFB27LuaHost.v1.${pid}`; - function request(command, params = {}) { + function request(command, params = {}, { + hostErrorValidator, + successResponseValidator, + } = {}) { if (typeof command !== 'string' || !command || !params || typeof params !== 'object') { return Promise.reject(new Cfb27HookError('INVALID_REQUEST', 'Command and params are invalid')); } @@ -303,6 +480,23 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { return; } for (const response of responses) { + if (response?.ok === false && typeof hostErrorValidator === 'function') { + try { + const code = hostErrorValidator(response, id); + finish(new Cfb27HookError(code, 'Host rejected the request')); + } catch (error) { + finish(error); + } + return; + } + if (typeof successResponseValidator === 'function') { + try { + successResponseValidator(response, id); + } catch (error) { + finish(error); + return; + } + } if (!response || response.protocol !== 1) { finish(new Cfb27HookError('PROTOCOL_MISMATCH', 'Host protocol version does not match')); return; @@ -346,6 +540,18 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { }); } + async function requireAllocationMetadataCapability() { + const hello = await request('hello'); + if (!hello || hello.protocolVersion !== 1 || + !Array.isArray(hello.capabilities) || + !hello.capabilities.includes('memoryScanAllocationMetadata')) { + throw new Cfb27HookError( + 'PROTOCOL_MISMATCH', + 'Host does not advertise memoryScanAllocationMetadata capability', + ); + } + } + return Object.freeze({ request, async hello() { @@ -372,10 +578,16 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { }, async scanMemoryPage(options = {}) { const params = cloneScanPageOptions(options); + if (params.includeAllocationMetadata === true) { + await requireAllocationMetadataCapability(); + } return validateScanPageResult(await request('scanMemory', params), params); }, async scanMemory(options = {}) { const { pageOptions, maxPages } = cloneAggregateScanOptions(options); + if (pageOptions.includeAllocationMetadata === true) { + await requireAllocationMetadataCapability(); + } const matches = []; const cursors = new Set(); let cursor; @@ -415,6 +627,19 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { const params = cloneReadOptions(options); return validateReadResult(await request('readMemory', params), params); }, + async writeTransaction(options = {}) { + const params = cloneWriteTransactionOptions(options); + try { + const result = await request('writeTransaction', params, { + hostErrorValidator: (response, id) => + validateWriteTransactionErrorResponse(response, id, params), + successResponseValidator: validateWriteTransactionSuccessResponse, + }); + return validateWriteTransactionResult(result, params); + } catch (error) { + throw sanitizeWriteTransactionError(error); + } + }, async registerTelemetryTypes(types) { const clonedTypes = cloneTelemetryTypes(types); return validateTelemetryRegistration( diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index ef6c89b..c80da21 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -13,6 +13,11 @@ const ERROR_CODES = Object.freeze([ 'INVALID_RESPONSE', 'SCRIPT_ERROR', 'MEMORY_ACCESS_DENIED', + 'MEMORY_MISMATCH', + 'TRANSACTION_LIMIT_EXCEEDED', + 'TRANSACTION_APPLY_FAILED', + 'ROLLBACK_VERIFICATION_FAILED', + 'SESSION_WRITES_DISABLED', 'SCAN_LIMIT_EXCEEDED', 'TOO_MANY_MATCHES', 'INSTALLATION_CONFLICT', diff --git a/packages/sdk/test/client.test.cjs b/packages/sdk/test/client.test.cjs index b265b76..3c6c1a6 100644 --- a/packages/sdk/test/client.test.cjs +++ b/packages/sdk/test/client.test.cjs @@ -39,6 +39,41 @@ async function fakeClient(t, responder) { return createClient({ pipeName, timeoutMs: 1000 }); } +async function fakeErrorClient(t, errorFactory) { + const pipeName = testPipeName('transaction-error'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + socket.end(encodeFrame({ + protocol: 1, + id: request.id, + ok: false, + error: errorFactory(request), + })); + } + }); + }); + await listen(server, pipeName); + t.after(() => server.close()); + return createClient({ pipeName, timeoutMs: 1000 }); +} + +async function fakeRawResponseClient(t, responseFactory) { + const pipeName = testPipeName('transaction-raw-error'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + socket.end(encodeFrame(responseFactory(request))); + } + }); + }); + await listen(server, pipeName); + t.after(() => server.close()); + return createClient({ pipeName, timeoutMs: 1000 }); +} + const VALID_SCAN_OPTIONS = Object.freeze({ patternHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', maskHex: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', @@ -71,12 +106,330 @@ const VALID_READ_RESULT = Object.freeze({ })]), }); +const VALID_ALLOCATION_SCAN_RESULT = Object.freeze({ + ...VALID_SCAN_RESULT, + matches: Object.freeze([Object.freeze({ + ...VALID_SCAN_RESULT.matches[0], + allocationBase: '0x7FF612340000', + allocationSize: 65536, + allocationProtect: 4, + offsetInAllocation: 128, + })]), +}); + +const VALID_TRANSACTION_REQUEST = Object.freeze({ + transactionId: 'recruiting.influence-proof-1', + operations: Object.freeze([Object.freeze({ + address: '0x7FF612340000', + expectedHex: '1020', + replacementHex: '1121', + })]), +}); + +const VALID_TRANSACTION_RESULT = Object.freeze({ + transactionId: 'recruiting.influence-proof-1', + status: 'applied_verified', + operations: Object.freeze([Object.freeze({ index: 0, applied: true, verified: true })]), +}); + test('SDK publishes stable memory error codes', () => { for (const code of ['MEMORY_ACCESS_DENIED', 'SCAN_LIMIT_EXCEEDED', 'TOO_MANY_MATCHES']) { assert.ok(ERROR_CODES.includes(code), `missing ${code}`); } }); +test('SDK publishes all stable guarded transaction error codes', () => { + for (const code of [ + 'MEMORY_ACCESS_DENIED', + 'MEMORY_MISMATCH', + 'TRANSACTION_LIMIT_EXCEEDED', + 'TRANSACTION_APPLY_FAILED', + 'ROLLBACK_VERIFICATION_FAILED', + 'SESSION_WRITES_DISABLED', + ]) { + assert.ok(ERROR_CODES.includes(code), `missing ${code}`); + } +}); + +test('writeTransaction clones caller input and sends the exact typed command', async (t) => { + const requests = []; + let serializedParams; + const originalStringify = JSON.stringify; + JSON.stringify = function captureFrozenTransaction(value, ...args) { + if (value?.command === 'writeTransaction') serializedParams = value.params; + return originalStringify.call(this, value, ...args); + }; + t.after(() => { JSON.stringify = originalStringify; }); + const client = await fakeClient(t, (request) => { + requests.push({ command: request.command, params: request.params }); + return VALID_TRANSACTION_RESULT; + }); + const input = { + transactionId: VALID_TRANSACTION_REQUEST.transactionId, + operations: [{ ...VALID_TRANSACTION_REQUEST.operations[0] }], + }; + const pending = client.writeTransaction(input); + input.transactionId = 'mutated'; + input.operations[0].address = '0x1'; + input.operations.push({ address: '0x2', expectedHex: '00', replacementHex: '01' }); + + const result = await pending; + assert.deepEqual(result, VALID_TRANSACTION_RESULT); + assert.equal(Object.isFrozen(serializedParams), true); + assert.equal(Object.isFrozen(serializedParams.operations), true); + assert.equal(Object.isFrozen(serializedParams.operations[0]), true); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.operations), true); + assert.equal(Object.isFrozen(result.operations[0]), true); + assert.deepEqual(requests, [{ + command: 'writeTransaction', + params: VALID_TRANSACTION_REQUEST, + }]); +}); + +test('writeTransaction rejects malformed or unsafe requests before creating a socket', async () => { + const originalCreateConnection = net.createConnection; + let socketCreations = 0; + net.createConnection = (...args) => { + socketCreations += 1; + return originalCreateConnection(...args); + }; + try { + const client = createClient({ pipeName: testPipeName('unused'), timeoutMs: 25 }); + const operation = { ...VALID_TRANSACTION_REQUEST.operations[0] }; + const cases = [ + undefined, + {}, + { ...VALID_TRANSACTION_REQUEST, extra: true }, + { ...VALID_TRANSACTION_REQUEST, status: 'applied_verified' }, + { ...VALID_TRANSACTION_REQUEST, result: VALID_TRANSACTION_RESULT }, + { ...VALID_TRANSACTION_REQUEST, transactionId: '' }, + { ...VALID_TRANSACTION_REQUEST, transactionId: 'a'.repeat(65) }, + { ...VALID_TRANSACTION_REQUEST, transactionId: 'invalid id' }, + { ...VALID_TRANSACTION_REQUEST, operations: [] }, + { ...VALID_TRANSACTION_REQUEST, operations: Array.from({ length: 33 }, () => operation) }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, extra: true }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, status: 'applied' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, address: 0x1234 }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, address: '0x7ff612340000' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, address: '0x0001' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, expectedHex: '0' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, expectedHex: 'GG' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, expectedHex: 'aa' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, replacementHex: '' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, replacementHex: '1' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, replacementHex: 'gg' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ ...operation, replacementHex: '112233' }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ + ...operation, + expectedHex: '00'.repeat(4097), + replacementHex: '11'.repeat(4097), + }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [ + { address: '0x1000', expectedHex: '0000', replacementHex: '1111' }, + { address: '0x1001', expectedHex: '00', replacementHex: '11' }, + ] }, + { ...VALID_TRANSACTION_REQUEST, operations: [ + { address: '0x1001', expectedHex: '00', replacementHex: '11' }, + { address: '0x1000', expectedHex: '0000', replacementHex: '1111' }, + ] }, + { ...VALID_TRANSACTION_REQUEST, operations: [ + ...Array.from({ length: 16 }, (_, index) => ({ + address: `0x${(0x10000 + index * 0x2000).toString(16).toUpperCase()}`, + expectedHex: '00'.repeat(4096), + replacementHex: '11'.repeat(4096), + })), + { address: '0x30000', expectedHex: '00', replacementHex: '11' }, + ] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ + address: '0xFFFFFFFFFFFFFFFF', expectedHex: '0000', replacementHex: '1111', + }] }, + { ...VALID_TRANSACTION_REQUEST, operations: [{ + address: '0xFFFFFFFFFFFFFFFF', expectedHex: '00', replacementHex: '11', + }] }, + ]; + for (const input of cases) { + await assert.rejects( + Promise.resolve().then(() => client.writeTransaction(input)), + (error) => error.code === 'INVALID_REQUEST', + ); + } + assert.equal(socketCreations, 0); + } finally { + net.createConnection = originalCreateConnection; + } +}); + +test('writeTransaction accepts a one-byte range immediately below the uint64 ceiling', async (t) => { + const request = { + transactionId: 'boundary.valid-1', + operations: [{ + address: '0xFFFFFFFFFFFFFFFE', expectedHex: '00', replacementHex: '11', + }], + }; + const client = await fakeClient(t, () => ({ + transactionId: request.transactionId, + status: 'applied_verified', + operations: [{ index: 0, applied: true, verified: true }], + })); + assert.equal((await client.writeTransaction(request)).status, 'applied_verified'); +}); + +test('writeTransaction rejects a one-byte range at the uint64 ceiling', async () => { + const client = createClient({ pipeName: testPipeName('unused'), timeoutMs: 25 }); + await assert.rejects( + Promise.resolve().then(() => client.writeTransaction({ + transactionId: 'boundary.invalid-1', + operations: [{ + address: '0xFFFFFFFFFFFFFFFF', expectedHex: '00', replacementHex: '11', + }], + })), + (error) => error.code === 'INVALID_REQUEST', + ); +}); + +test('writeTransaction strictly validates every host result property', async (t) => { + const invalidResults = [ + undefined, + {}, + { ...VALID_TRANSACTION_RESULT, extra: true }, + { ...VALID_TRANSACTION_RESULT, transactionId: 'other.transaction' }, + { ...VALID_TRANSACTION_RESULT, status: 'rolled_back_verified' }, + { ...VALID_TRANSACTION_RESULT, operations: 'applied' }, + { ...VALID_TRANSACTION_RESULT, operations: [] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], extra: true }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], index: 1 }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], applied: 1 }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], applied: false }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], verified: 'true' }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], verified: false }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], address: '0x7FF612340000' }] }, + { ...VALID_TRANSACTION_RESULT, operations: [{ ...VALID_TRANSACTION_RESULT.operations[0], bytesHex: '1020' }] }, + ]; + let index = 0; + const client = await fakeClient(t, () => invalidResults[index++]); + for (const ignored of invalidResults) { + await assert.rejects( + client.writeTransaction(VALID_TRANSACTION_REQUEST), + (error) => error.code === 'INVALID_RESPONSE', + ); + } +}); + +test('writeTransaction sanitizes allowlisted host errors and discards hostile details', async (t) => { + const codes = [ + 'INVALID_REQUEST', + 'UNSUPPORTED_BUILD', + 'MEMORY_ACCESS_DENIED', + 'MEMORY_MISMATCH', + 'TRANSACTION_LIMIT_EXCEEDED', + 'TRANSACTION_APPLY_FAILED', + 'ROLLBACK_VERIFICATION_FAILED', + 'SESSION_WRITES_DISABLED', + ]; + let index = 0; + const client = await fakeErrorClient(t, () => { + const code = codes[index++]; + const error = { + code, + message: 'address 0x7FF612340000 expected 1020 actual DEADBEEF', + }; + if (code === 'TRANSACTION_APPLY_FAILED' || code === 'ROLLBACK_VERIFICATION_FAILED') { + error.details = { + transactionId: VALID_TRANSACTION_REQUEST.transactionId, + status: code === 'TRANSACTION_APPLY_FAILED' + ? 'rolled_back_verified' + : 'rollback_unverified', + operations: [{ index: 0, applied: true, verified: code === 'TRANSACTION_APPLY_FAILED' }], + }; + } else error.details = {}; + return error; + }); + for (const code of codes) { + await assert.rejects(client.writeTransaction(VALID_TRANSACTION_REQUEST), (error) => { + assert.equal(error.code, code); + assert.equal(error.details, undefined); + assert.equal(error.message.includes('0x7FF612340000'), false); + assert.equal(error.message.includes('1020'), false); + assert.equal(error.message.includes('DEADBEEF'), false); + return true; + }); + } +}); + +test('writeTransaction validates raw host error envelopes before generic conversion', async (t) => { + const hostile = '0x7FF612340000 expected 1020 actual DEADBEEF'; + const responseFactories = [ + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'HOSTILE_CODE', message: hostile, details: {} } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', message: 42, details: {} } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', details: {} } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', message: hostile } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', message: hostile, + details: {}, address: '0x7FF612340000', bytesHex: 'DEADBEEF' } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'TRANSACTION_APPLY_FAILED', message: hostile, details: 'DEADBEEF' } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', message: hostile, + details: { address: '0x7FF612340000', bytesHex: 'DEADBEEF' } } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'TRANSACTION_APPLY_FAILED', message: hostile, details: { + transactionId: VALID_TRANSACTION_REQUEST.transactionId, + status: 'rolled_back_verified', + operations: [{ index: 0, applied: true, verified: true, bytesHex: 'DEADBEEF' }], + } } }), + (request) => ({ protocol: 1, id: request.id, ok: false, + error: { code: 'MEMORY_MISMATCH', message: hostile, details: {} }, + bytesHex: 'DEADBEEF' }), + (request) => ({ protocol: 1, id: request.id, ok: false }), + (request) => ({ protocol: 1, id: request.id, ok: false, error: 'not-an-error-object' }), + ]; + let index = 0; + const client = await fakeRawResponseClient(t, (request) => responseFactories[index++](request)); + for (const ignored of responseFactories) { + await assert.rejects(client.writeTransaction(VALID_TRANSACTION_REQUEST), (error) => { + assert.equal(error.code, 'INVALID_RESPONSE'); + assert.equal(error.details, undefined); + assert.equal(error.message.includes('0x7FF612340000'), false); + assert.equal(error.message.includes('1020'), false); + assert.equal(error.message.includes('DEADBEEF'), false); + return true; + }); + } +}); + +test('writeTransaction rejects hostile raw success envelopes', async (t) => { + const responseFactories = [ + (request) => ({ protocol: 1, id: request.id, ok: true, + result: VALID_TRANSACTION_RESULT, address: '0x7FF612340000' }), + (request) => ({ protocol: 1, id: request.id, ok: true, + result: VALID_TRANSACTION_RESULT, bytesHex: 'DEADBEEF' }), + (request) => ({ id: request.id, ok: true, result: VALID_TRANSACTION_RESULT }), + () => ({ protocol: 1, ok: true, result: VALID_TRANSACTION_RESULT }), + (request) => ({ protocol: 1, id: request.id, result: VALID_TRANSACTION_RESULT }), + (request) => ({ protocol: 1, id: request.id, ok: true }), + (request) => ({ protocol: 2, id: request.id, ok: true, result: VALID_TRANSACTION_RESULT }), + (request) => ({ protocol: 1, id: `${request.id}-hostile`, ok: true, + result: VALID_TRANSACTION_RESULT }), + (request) => ({ protocol: 1, id: request.id, ok: 'true', + result: VALID_TRANSACTION_RESULT }), + (request) => ({ protocol: 1, id: request.id, ok: true, result: null }), + ]; + let index = 0; + const client = await fakeRawResponseClient(t, (request) => responseFactories[index++](request)); + for (const ignored of responseFactories) { + await assert.rejects(client.writeTransaction(VALID_TRANSACTION_REQUEST), (error) => { + assert.equal(error.code, 'INVALID_RESPONSE'); + assert.equal(error.details, undefined); + return true; + }); + } +}); + test('client negotiates hello and preserves multiline evaluate', async (t) => { const pipeName = `\\\\.\\pipe\\cfb27-test-${process.pid}-${Date.now()}`; const server = net.createServer((socket) => { @@ -223,6 +576,100 @@ test('memory APIs clone options and send exact typed commands', async (t) => { ]); }); +test('opt-in allocation scans clone the exact boolean and preflight the capability', async (t) => { + const requests = []; + const client = await fakeClient(t, (request) => { + requests.push({ command: request.command, params: request.params }); + return request.command === 'hello' + ? { protocolVersion: 1, capabilities: ['memoryScanAllocationMetadata'] } + : request.params.includeAllocationMetadata + ? VALID_ALLOCATION_SCAN_RESULT + : VALID_SCAN_RESULT; + }); + const options = { ...VALID_SCAN_OPTIONS, includeAllocationMetadata: true }; + const pending = client.scanMemoryPage(options); + options.includeAllocationMetadata = false; + assert.deepEqual(await pending, VALID_ALLOCATION_SCAN_RESULT); + assert.deepEqual(requests, [ + { command: 'hello', params: {} }, + { command: 'scanMemory', params: { + ...VALID_SCAN_OPTIONS, + includeAllocationMetadata: true, + } }, + ]); + + requests.length = 0; + assert.deepEqual( + await client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, includeAllocationMetadata: false }), + VALID_SCAN_RESULT, + ); + assert.deepEqual(requests, [{ command: 'scanMemory', params: { + ...VALID_SCAN_OPTIONS, + includeAllocationMetadata: false, + } }]); +}); + +test('opt-in allocation scans fail closed when the capability is absent', async (t) => { + const commands = []; + const client = await fakeClient(t, (request) => { + commands.push(request.command); + return { protocolVersion: 1, capabilities: ['memoryScan'] }; + }); + await assert.rejects( + client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, includeAllocationMetadata: true }), + (error) => error.code === 'PROTOCOL_MISMATCH' && + error.message === 'Host does not advertise memoryScanAllocationMetadata capability', + ); + assert.deepEqual(commands, ['hello']); +}); + +test('aggregate allocation scans preflight once and preserve extended matches', async (t) => { + const commands = []; + const client = await fakeClient(t, (request) => { + commands.push(request.command); + return request.command === 'hello' + ? { protocolVersion: 1, capabilities: ['memoryScanAllocationMetadata'] } + : VALID_ALLOCATION_SCAN_RESULT; + }); + assert.deepEqual( + await client.scanMemory({ + ...VALID_SCAN_OPTIONS, + includeAllocationMetadata: true, + maxPages: 1, + }), + { + supportedBuild: true, + complete: true, + scannedBytes: 65536, + matches: VALID_ALLOCATION_SCAN_RESULT.matches, + }, + ); + assert.deepEqual(commands, ['hello', 'scanMemory']); +}); + +test('opt-in allocation scans reject hostile metadata shapes and arithmetic', async (t) => { + const validMatch = VALID_ALLOCATION_SCAN_RESULT.matches[0]; + const { allocationBase, ...missingBase } = validMatch; + const invalidMatches = [ + missingBase, + { ...validMatch, extra: true }, + { ...validMatch, allocationBase: '0x7ff612340000' }, + { ...validMatch, allocationSize: Number.MAX_SAFE_INTEGER + 1 }, + { ...validMatch, offsetInAllocation: validMatch.allocationSize }, + { ...validMatch, offsetInAllocation: 127 }, + ]; + let index = 0; + const client = await fakeClient(t, (request) => request.command === 'hello' + ? { protocolVersion: 1, capabilities: ['memoryScanAllocationMetadata'] } + : { ...VALID_ALLOCATION_SCAN_RESULT, matches: [invalidMatches[index++]] }); + for (const ignored of invalidMatches) { + await assert.rejects( + client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, includeAllocationMetadata: true }), + (error) => error.code === 'INVALID_RESPONSE', + ); + } +}); + test('memory APIs reject invalid requests before creating a socket', async () => { const originalCreateConnection = net.createConnection; let socketCreations = 0; @@ -245,6 +692,7 @@ test('memory APIs reject invalid requests before creating a socket', async () => { ...VALID_SCAN_OPTIONS, maxMatches: Number.MAX_SAFE_INTEGER + 1 }, { ...VALID_SCAN_OPTIONS, contextBefore: 256, contextAfter: 257 }, { ...VALID_SCAN_OPTIONS, allowUnsupportedBuild: 'true' }, + { ...VALID_SCAN_OPTIONS, includeAllocationMetadata: 1 }, { ...VALID_SCAN_OPTIONS, cursor: '0xabcdef' }, { ...VALID_SCAN_OPTIONS, cursor: 4096 }, ]; diff --git a/scripts/package-release.cjs b/scripts/package-release.cjs index bc411f5..623e632 100644 --- a/scripts/package-release.cjs +++ b/scripts/package-release.cjs @@ -6,7 +6,7 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const root = path.resolve(__dirname, '..'); -const version = '0.2.0-dev.1'; +const version = '0.2.0-dev.2'; function releaseEntries() { return ['native', 'packages', 'examples', 'docs', 'README.md', 'LICENSE']; diff --git a/tests/native-layout.test.cjs b/tests/native-layout.test.cjs index a1c3743..0919bca 100644 --- a/tests/native-layout.test.cjs +++ b/tests/native-layout.test.cjs @@ -24,3 +24,23 @@ test('active native tree contains only host, proxy, and smoke entry points', () assert.doesNotMatch(cmake, /hook\.cpp|injector\.cpp|response_guard\.cpp/); assert.doesNotMatch(cmake, /archive[\\/]/); }); + +test('Windows CI enables writes only for the protocol smoke step', () => { + const workflow = fs.readFileSync( + path.join(root, '.github/workflows/windows-ci.yml'), + 'utf8', + ); + const lines = workflow.split(/\r?\n/); + const command = ' - run: native/build-release/Release/cfb27_protocol_smoke.exe ' + + 'native/build-release/Release/cfb27_lua_host.dll'; + const stepStart = lines.indexOf(command); + assert.notEqual(stepStart, -1, 'protocol smoke command must remain exact'); + const nextStep = lines.findIndex((line, index) => index > stepStart && line.startsWith(' - ')); + const step = lines.slice(stepStart, nextStep === -1 ? lines.length : nextStep); + assert.deepEqual(step, [ + command, + ' env:', + " CFB27_SMOKE_ALLOW_WRITES: '1'", + ]); + assert.equal(lines.filter((line) => line.includes('CFB27_SMOKE_ALLOW_WRITES')).length, 1); +}); diff --git a/tests/package-layout.test.cjs b/tests/package-layout.test.cjs index 2b0be92..f4295d8 100644 --- a/tests/package-layout.test.cjs +++ b/tests/package-layout.test.cjs @@ -21,11 +21,32 @@ test('SDK and CLI package identities are stable', () => { const sdk = readJson('packages/sdk/package.json'); const cli = readJson('packages/cli/package.json'); assert.equal(sdk.name, '@cfb27/lua-hook'); - assert.equal(sdk.version, '0.2.0-dev.1'); + assert.equal(sdk.version, '0.2.0-dev.2'); assert.equal(cli.name, 'cfb27-lua-hook'); assert.equal(cli.bin.cfb27lua, 'bin/cfb27lua.cjs'); }); +test('Windows CI uploads the current preview version', () => { + const pkg = readJson('package.json'); + const workflow = fs.readFileSync( + path.join(root, '.github/workflows/windows-ci.yml'), 'utf8', + ); + assert.match( + workflow, + new RegExp(`name: cfb27-lua-hook-${pkg.version.replaceAll('.', '\\.')}`), + ); +}); + +test('Windows CI executes the memory transaction smoke', () => { + const workflow = fs.readFileSync( + path.join(root, '.github/workflows/windows-ci.yml'), 'utf8', + ); + assert.match( + workflow, + /- run: native\/build-release\/Release\/cfb27_memory_transaction_smoke\.exe/, + ); +}); + test('repository is MIT licensed', () => { const license = fs.readFileSync(path.join(root, 'LICENSE'), 'utf8'); assert.match(license, /MIT License/);