From e4d3e1399d9cd4932653e7015a1bf8589e56029d Mon Sep 17 00:00:00 2001 From: Quentin Limo <85309073+3QL@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:35:55 +0200 Subject: [PATCH 01/93] feat(flutter): identify Dart snapshot builds and inspect AOT payloads by pure APK parsing (#1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flutter): identify Dart snapshot builds by pure APK parsing Add a flutter-provider family with one operation, identify_flutter_build, that reads an APK's lib//libapp.so and libflutter.so through the hardened ZIP reader and reports build facts as observations: - The Dart VM snapshot hash, read 20 bytes after every snapshot magic (f5f5dcdc). The vm and isolate sections are cross-checked: agreeing sections collapse to one snapshot_hash, disagreeing sections keep every candidate with snapshot_hash null and partial coverage. - Per-ABI library digests and byte sizes, plus the engine library's GNU build-id note and any labeled 'Dart VM version' or Android clang/LLVM toolchain strings, verbatim; release builds without a version string report null rather than a guess. - The APK is digested before parsing so Evidence binds the observation to the exact input, and a target with no libapp.so reports not-detected with complete coverage. The scanner is streaming with explicit pending-magic and rescan bookkeeping, so chunk boundaries cannot double-count or drop sections, and magic-free streams never accumulate. No engine, no external lookups: the hash-to-SDK mapping stays with external datasets by design. Verified against Flutter Gallery 2.9.2 (112 MB, three ABIs): every ABI reports the same agreeing hash 65817c30a78bb44c3dc3771876b6010a from two sections, per-ABI build-ids, and the clang toolchain line; a plain aapt2-built APK reports not-detected with complete coverage. Scanner goldens come from the same libraries. READMEs carry the new 'Flutter apps' row in every supported language. * feat(flutter): inspect Dart AOT snapshots through the ELF symbol table Add inspect_dart_aot to the flutter family: for one ABI's libapp.so it locates the four _kDart*Snapshot* symbols through the ELF dynamic symbol table (ELF32 and ELF64 little-endian, bounds-checked reads; a payload without exported symbols is refused), reports each section's offset and size, validates data-section headers (magic, kind, length; instructions sections honestly report nulls for the fields their layout does not carry), and projects a bounded string pool from the isolate data section: distinct package: source URIs (the app's dependency inventory), dart: SDK URIs, and identifier-shaped tokens, each with counts, bounds, and partial coverage when truncated. The string pool is labeled as a byte-pattern projection, not deserialized cluster semantics: typed class and function recovery remains an SDK-specific parser, which the snapshot hash now unlocks. The ABI is caller-selected with a deterministic default (arm64-v8a, x86_64, then first available). Verified against Flutter Gallery 2.9.2: all four symbols on both ELF64 (arm64-v8a) and ELF32 (armeabi-v7a) images, header validation, hash agreement with identify_flutter_build, and a 756 package: / 201 dart: URI pool from the isolate data section; goldens in DartElf.test.ts come from the same libraries. * fix(flutter): own snapshots, bound image reads, and preserve cancellation Mirror the ownership bar the maintainers applied across the merged JEB, ADB, and Apktool PRs: - Copy the admitted APK into a provider-owned, read-only snapshot (EXCL create, chmod 0400) before digesting and parsing; every later read uses the snapshot, so a concurrently modified original cannot mix into one observation. The result reports the snapshot's byte size and digest. - Retain failed snapshot-root cleanup in a provider set and retry it on close(), throwing an aggregate error when a root is still owned; root removal is injectable so the retention is testable. - Allocate the AOT image buffer at exactly the declared library size before reading (bounded before allocation, not concat-after-stream), keep the 256 MiB ceiling, and check cancellation between digest and stream chunks through a captured abort reason. - Accept APK paths containing spaces (covered by a fixture test) and escape the ABI pattern's dash for v-mode regex compatibility. Also add tests for cleanup retention with close retry and for cancellation before any snapshot root is created. * docs(flutter): cover the Italian README with the Flutter row The Italian translation landed between the merged feature PRs; its table now carries every merged row plus this one, keeping its documentation-link set identical to README.md. * fix(flutter): make the ABI pattern portable for v-mode validators Use a hex-escaped hyphen in the ABI character class, matching the form the ADB portability fix uses, so clients compiling advertised schemas in unicodeSets mode accept the inspect_dart_aot input schema. * refactor(flutter): reuse shared harnesses and deduplicate the vertical The duplicate-detection gate (jscpd, 2.5% threshold) tipped to 2.51% with the flutter files added. Reuse the existing machinery instead of repeating it: - FlutterBuildAnalysisService delegates to executeProviderOperation, the shared admission/Evidence composition, instead of restating its body. - The CLI registers both commands from one table-driven spec. - Boundary tests use the shared serveCli and assertAdvertisedMcpContracts harnesses rather than hand-rolled equivalents. - The provider test consolidates the four payload-shape cases into one table-driven case; the service test uses one admission table and distinct phrasings. Duplicate lines fall from 2.51% to 2.48%, back under the gate. * fix(flutter): address review findings from real-app verification CassieFox23 verified the PR against three real F-Droid Flutter apps and a control app, and found five gaps. All are fixed and re-verified against cone (info.tangential.cone), one of the tested apps: - Build-id: the scanner now reads the descriptor length from the note header instead of assuming 20 bytes, so 8-byte ids from older toolchains parse; the schema accepts 8-128 hex characters. Verified against cone's readelf-reported ids on all three ABIs. - Compiler line: the toolchain capture bound rises from 200 to 500 characters, keeping cone's 262-character clang line verbatim. - Zero-byte libapp.so: the extraction target is created before streaming, so an empty entry yields a clean unsupported_target refusal for inspect_dart_aot and elf_header_valid: false with partial coverage for identify_flutter_build, instead of a raw ENOENT with code UNKNOWN. - Truncated library: identify_flutter_build marks fewer than two snapshot sections as partial coverage with a limitation naming the count, and a missing ELF magic gets its own limitation. - Docs: the default ABI preference now matches the code exactly (arm64-v8a, x86_64, x86, armeabi-v7a, then the rest alphabetically). Also: every test that read the author's /tmp/opencode goldens now builds a self-contained synthetic ELF64 image with dynsym, four snapshot symbols, two snapshot sections, and a string pool, so the suite runs anywhere; the verify lane now requires a present hex build-id per ABI instead of accepting null. * fix(flutter): address the Codex review findings Eight findings, all applied: - P1: SnapshotHashScanner keeps candidates in a Set (no quadratic Array.includes) with a 64-distinct-hash cap, so a hostile payload of magic+hash records cannot stall the scan or bloat the response. - P1: projectStringPool stops admitting new distinct entries once each category reaches twice its projection bound and reports collectionCapped; allocation and sorting now track the response, not the payload. - P2: ZIP reader rejections during entry iteration or entry streaming map into the typed error algebra (cancellation vs input refusal) instead of escaping as unhandled exceptions in both operations. - P2: magic_valid now marks coverage only for the *Data symbols; a complete four-symbol image with valid data magics reports complete coverage (the instruction symbols intentionally lack the magic). - P2: the MCP server closes the Flutter provider during shutdown, so a retained snapshot root is retried and reported, not leaked. - P2: LabeledStringScanner commits matches only when a printable run is delimiter-terminated; the carry grows to 600 bytes so a 500-character toolchain line survives any chunk boundary untruncated. - P2: both Flutter tool effects declare writesFilesystem: true; the owned APK snapshot and extracted libapp.so are temporary writes. - P2: the verify lane asserts every ABI agrees on exactly one snapshot hash instead of discarding the computed set. * fix(flutter): address the second Codex review round Nine findings on the first-round fixes: - P1: identify_flutter_build enforces a cumulative extraction budget (768 MiB) across every library entry, not just per library, so an APK with many ABIs cannot stream an unbounded total. - P1: a single NUL-free printable run stops materializing at 1024 bytes and marks the pool capped, so an adversarial one-run section cannot allocate unbounded strings. - P1: ELF cString bounds the NUL-terminator scan to the name maximum plus one, so malformed symbol tables cannot scan the whole image per entry. - P2: mapReaderFailure distinguishes ArtifactReaderFailure texts — budget/limit rejections map to resource constraints, integrity failures (CRC/overlap/signature) to their own input refusal — instead of collapsing all reader failures into one message. - P2: the labeled-string carry is cleared when a delimiter commits a run, so consecutive toolchain lines are never concatenated. - P2: string-pool counts are renamed *_count_at_least and documented as lower bounds, because capped collection discards values beyond twice the projection bound. - P2: inspect_dart_aot marks hash disagreements, non-pair source counts, and candidate-cap saturation as partial coverage. - P2: only valid lowercase-hex hash records count as snapshot sources; complete magic records with non-hex fields do not inflate agreement. - P2: the per-entry size refusal in inspect_dart_aot moves after ABI selection, so an oversized unselected ABI no longer refuses a small request. * fix(flutter): address the third Codex review round Five findings: - P2: the owned snapshot revalidates the source after the copy (size, mtime, inode), so an in-place replacement or swap mid-copy refuses the observation instead of publishing mixed bytes. - P2: a failed ZipArtifactReader close retains the snapshot root for close() retry instead of silently swallowing the descriptor leak, in both operations. - P2: snapshot headers are parsed within their symbol bounds and a symbol smaller than the 16-byte header reports magic_valid false, so trailing-image bytes can no longer fake a valid data section. - P2: the verify lane counts provider.close() cleanup failures as lane failures instead of certifying a run with retained roots. - P2: LabeledStringScanner reports toolchain_capped when further distinct matching lines are observed past the 4-line projection; identify marks such results partial with a naming limitation. * fix(flutter): address rounds three and four of the Codex review Round three (one missed finding) plus round four (five findings): - P2: the GNU build-id note descriptor size is decoded from all four bytes of the little-endian field, rejecting nonzero high bytes instead of masking them to 16 bits. - P1: the Flutter MCP handlers route through createContractToolHandler and the session's analysisAdmission, so close_binary and snapshot operations drain in-flight Flutter calls instead of racing them. - P2: the owned-snapshot copy is a streamed, abort-checked write (no copyFile), so cancellation stops the write promptly while the source-identity revalidation (size, mtime, inode) still guards against mid-copy replacement. - P2: identify marks any snapshot_hash_sources value other than exactly two as partial, matching the inspection path's pair requirement. - P2: the verify lane refuses to run without --apk (exit 2) instead of printing a false-green pass, and the engine check requires every libapp-carrying ABI to have a present libflutter with a valid hex build-id rather than filtering missing engines away vacuously. * fix(flutter): address the fifth Codex review round Six findings plus the upstream duplication baseline: - P2: the flutter-provider kind reports available in the capability inventory, so binary_session no longer lists always-registered Flutter tools as provider_missing. - P2: printable runs that exceed the 1024-byte cap are discarded at their delimiter instead of emitting their truncated prefix as a fabricated observation. - P2: ELF symbols must fit inside the file-backed portion of their PT_LOAD segment (p_filesz), so addresses in gaps or BSS are refused instead of accepting unrelated bytes. - P2: the build-id carry spans the complete 80-byte maximum note (16-byte header + 64-byte descriptor) so chunk-boundary splits cannot drop the first byte. - P2: the labeled-string carry is sized for the toolchain pattern's true 605-byte maximum, not the previous 600. - P2: the AOT completeness predicate requires exactly two sources and one candidate unconditionally, so zero-source images with valid data magics report partial instead of complete. Also: the shared ELF fixture builder and snapshot-section helper moved to a dedicated FlutterTestFixtures module, eliminating cross-file test duplication; jscpd now passes at 2.50% while main sits at 2.52%. * fix(flutter): address the sixth Codex review round Five findings: - P2: snapshot-hash scanning is bounded to the exported data symbols' byte ranges, so decoy or stale magic/hash sequences elsewhere in the image cannot inflate source counts or contribute candidates. - P2: labeled-string runs longer than the carry bound are rejected instead of truncated, preventing a regex prefix from being published as a verbatim source string. - P2: the AOT image reread checks cancellation between file reads and before the synchronous parsing stage, so a cancelled caller cannot receive a success from a completed 256 MiB read. - P2: the verify lane requires exactly two snapshot sources per ABI, matching the provider's pair requirement, instead of accepting any count >= 2. - P2: ELF section and program header entry sizes are validated against the class-defined minimum widths before walking either table, so undersized entries cannot cause field reads to cross into neighboring bytes. * fix(flutter): address the seventh Codex review round Six findings: - P2: data-symbol ranges are deduplicated by offset and size, so aliased symbols mapping to the same bytes are scanned once and the result marks partial coverage with a limitation naming the shared range. - P2: openEntrySafely forwards return/throw to the underlying Readable's iterator, so an early for-await exit closes the ZIP entry stream and releases its producer instead of leaking a backpressured pipe. - P2: inspect_dart_aot now reports snapshot_hash_candidates and snapshot_hash_sources alongside snapshot_hash, so a null hash from conflicting evidence is distinguishable from missing hash data. - P2: overlong printable runs only set toolchain_capped when they begin with a recognized label prefix (Dart VM version: or Android (); other long strings such as license text are skipped without marking partial. - P2: readElfSymbol falls back to PT_DYNAMIC/DT_SYMTAB when section headers are absent (e_shnum 0, ET_DYN), so stripped Flutter libraries with exported snapshot symbols are no longer refused; the dynamic table is bounded by DT_STRSZ, DT_SYMENT, and the image end. - P2: the verify lane exercises inspect_dart_aot for every ABI carrying libapp.so, not just the preferred default. * fix(flutter): address the eighth Codex review round Five findings: - P2: each data-symbol range gets a fresh SnapshotHashScanner so the carry from one noncontiguous slice cannot fabricate a record spanning into the next; results are aggregated after scanning. - P2: dynsym name lookups are bounded to the linked string table — st_name offsets outside the table and terminators beyond it are rejected instead of reading unrelated payload bytes as symbol names, in both the section-header and PT_DYNAMIC paths. - P2: identify_flutter_build routes its reader.entries and reader.open calls through the same collectEntriesSafely and openEntrySafely wrappers as inspect_dart_aot, so malformed ZIPs surface as input refusals instead of AnalysisProtocolError. - P2: mapReaderFailure recognizes ArtifactReaderFailure texts carrying a cancelled reason, mapping them to AnalysisCancelledError instead of AnalysisInputError. - P2: the cumulative extraction cap is enforced inside the chunk loop, not after each library completes, so refusal happens before the remaining bytes are streamed and hashed. * fix(flutter): harden streaming, sizes, and cancellation after self-review Four issues found in a systematic self-audit across all bug classes from eight Codex rounds: - createReadStream in both the digest function and the owned-snapshot copy now receives the AbortSignal directly, so cancellation aborts the filesystem read at the source instead of waiting for the next chunk to arrive on a slow filesystem. - The owned snapshot enforces a 2 GiB APK-level size cap before any copy I/O, so a path pointing at an enormous file is refused before disk usage grows. - identify_flutter_build preflights the sum of declared entry sizes against the 768 MiB cumulative budget, refusing before any ZIP extraction when the declared total already exceeds it. - The LabeledStringScanner triple result() call is collapsed to a single invocation (the previous code called result() three times in the emission, relying on end() being idempotent). No behavior change for valid inputs; all 36 tests, check:fast, and the cone verify lane pass unchanged. * fix(flutter): require structurally parseable ELF headers for complete coverage CassieFox23's third corrupt-header case: an ELF with valid magic but zeroed section/program header offsets passes the 4-byte magic check and the byte scanner still finds hash patterns in the data sections, so identify_flutter_build reports complete coverage for a structurally broken file. The elf_header_valid check now also verifies beyond the magic: e_type must be a known value (ET_REL/ET_EXEC/ET_DYN), at least one of e_shoff/e_phoff must be non-zero, and at least one of e_shnum/e_phnum must be non-zero. A file with valid magic but zeroed structure reports partial coverage with the existing ELF-magic limitation. * fix(flutter): validate ELF and snapshot coverage Share bounded ELF header parsing between identification and inspection. Accumulate headers across ZIP chunks and validate table entry widths and extents before claiming complete identification coverage. Bind AOT hashes to the exported VM and isolate headers, validate stored snapshot lengths including the magic bytes, and preserve partial evidence for missing, malformed, aliased, or overlapping data headers. Preserve native stream cancellations and APK resource constraints across both operations. Bound snapshot copies while streaming and retain useful input-failure diagnostics and lower-bound count wording. Validation: 72 focused tests; check:fast; check including formatting; docs:check; test discovery; jscpd; real cone APK and non-Flutter control on Linux. Verified CLI/MCP parity for valid and corrupted ELF headers and AOT inspection across three ABIs. Native Windows and macOS were not run. * fix(flutter): unify artifact ownership and bound ELF evidence * fix(flutter): preserve ELF provenance and partial coverage Read GNU build IDs only from bounded ELF note ranges and retain conflicting candidates. Preserve source versus owned storage failures and honor cancellation through parsing and final cleanup. Separate snapshot structure from string-pool coverage. Verify every Gallery ABI with digest-bound engine labels and URI counts, and document the supported snapshot-kind layout. --------- Co-authored-by: REA Contributor Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- README.md | 1 + README_ar.md | 1 + README_de.md | 1 + README_es.md | 1 + README_fa.md | 1 + README_fr.md | 1 + README_id.md | 1 + README_it.md | 1 + README_ja.md | 1 + README_ko.md | 1 + README_pl.md | 1 + README_pt-BR.md | 1 + README_ru.md | 1 + README_th.md | 1 + README_tr.md | 1 + README_uk.md | 1 + README_vi.md | 1 + README_zh-TW.md | 1 + README_zh.md | 1 + docs/flutter-analysis.md | 156 ++ docs/testing.md | 30 + package.json | 1 + .../flutter-gallery-expectations.json | 38 + scripts/lib/catalog-builders.mjs | 9 + scripts/lib/catalog-core.mjs | 4 + scripts/verify-flutter.mjs | 259 ++++ src/application/CapabilityInventory.ts | 2 + .../flutter/FlutterBuildAnalysisPort.ts | 23 + .../FlutterBuildAnalysisService.test.ts | 93 ++ .../flutter/FlutterBuildAnalysisService.ts | 31 + src/artifacts/RegularFileAdmission.test.ts | 24 +- src/cli.ts | 2 + src/cli/flutterCommands.ts | 97 ++ src/cliCommandNames.ts | 2 + src/composition/flutter.ts | 6 + src/contracts/flutter/flutterToolContracts.ts | 45 + src/contracts/toolContractTypes.ts | 1 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.test.ts | 3 + src/contracts/toolEffects.ts | 8 + src/domain/flutter/flutterBuildAnalysis.ts | 173 +++ src/filesystem/RegularFile.ts | 7 +- src/flutter/DartElf.test.ts | 228 +++ src/flutter/DartElf.ts | 506 +++++++ src/flutter/ElfBuildId.ts | 158 ++ src/flutter/FlutterBuild.fixture.ts | 267 ++++ src/flutter/FlutterBuildProvider.ts | 1230 +++++++++++++++ src/flutter/FlutterPayloadScan.test.ts | 209 +++ src/flutter/FlutterPayloadScan.ts | 217 +++ src/server/createServer.ts | 15 + src/server/registerFlutterTools.ts | 35 + tests/acceptance/flutterAnalysis.test.ts | 154 ++ tests/boundary/cli/flutterCli.test.ts | 21 + .../filesystem/flutterBuildProvider.test.ts | 1332 +++++++++++++++++ tests/boundary/mcp/flutterMcp.test.ts | 183 +++ tests/fixtures/flutterApk.ts | 39 + vitest.config.ts | 2 +- 57 files changed, 5627 insertions(+), 4 deletions(-) create mode 100644 docs/flutter-analysis.md create mode 100644 scripts/fixtures/flutter-gallery-expectations.json create mode 100644 scripts/verify-flutter.mjs create mode 100644 src/application/flutter/FlutterBuildAnalysisPort.ts create mode 100644 src/application/flutter/FlutterBuildAnalysisService.test.ts create mode 100644 src/application/flutter/FlutterBuildAnalysisService.ts create mode 100644 src/cli/flutterCommands.ts create mode 100644 src/composition/flutter.ts create mode 100644 src/contracts/flutter/flutterToolContracts.ts create mode 100644 src/domain/flutter/flutterBuildAnalysis.ts create mode 100644 src/flutter/DartElf.test.ts create mode 100644 src/flutter/DartElf.ts create mode 100644 src/flutter/ElfBuildId.ts create mode 100644 src/flutter/FlutterBuild.fixture.ts create mode 100644 src/flutter/FlutterBuildProvider.ts create mode 100644 src/flutter/FlutterPayloadScan.test.ts create mode 100644 src/flutter/FlutterPayloadScan.ts create mode 100644 src/server/registerFlutterTools.ts create mode 100644 tests/acceptance/flutterAnalysis.test.ts create mode 100644 tests/boundary/cli/flutterCli.test.ts create mode 100644 tests/boundary/filesystem/flutterBuildProvider.test.ts create mode 100644 tests/boundary/mcp/flutterMcp.test.ts create mode 100644 tests/fixtures/flutterApk.ts diff --git a/README.md b/README.md index fe061ddcf9..83b8f03bc7 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Additional tools and host support depend on the target: | Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) | | JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) | | Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) | +| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) | | Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) | | Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) | | Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) | diff --git a/README_ar.md b/README_ar.md index d45af6edcc..6b050084f1 100644 --- a/README_ar.md +++ b/README_ar.md @@ -144,6 +144,7 @@ npx rea-agents@latest setup | أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) | | مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](docs/jeb-analysis.md) | | موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](docs/apktool-resource-analysis.md) | +| تطبيقات Flutter | بصمة لقطة Dart وهشوش المكتبات لكل ABI وأصل بناء المحرك | لا شيء (تحليل APK ساكن)؛ [دليل Flutter](docs/flutter-analysis.md) | | البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) | | الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) | | سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) | diff --git a/README_de.md b/README_de.md index 530527b899..d10f5f4b7b 100644 --- a/README_de.md +++ b/README_de.md @@ -144,6 +144,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) | | JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](docs/jeb-analysis.md) | | Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](docs/apktool-resource-analysis.md) | +| Flutter-Apps | Dart-Snapshot-Hash, Per-ABI-Bibliotheksprüfsummen und Engine-Build-Provenienz | Keine (statische APK-Analyse); [Flutter-Leitfaden](docs/flutter-analysis.md) | | Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](docs/firmware-analysis.md) | | Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](docs/javascript-artifact-reconstruction.md), [Apple-Anwendungen](docs/apple-application-analysis.md) | | Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](docs/process-capture.md) | diff --git a/README_es.md b/README_es.md index f23bd2df74..883cef8638 100644 --- a/README_es.md +++ b/README_es.md @@ -144,6 +144,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) | | Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](docs/jeb-analysis.md) | | Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](docs/apktool-resource-analysis.md) | +| Aplicaciones Flutter | Hash de snapshot de Dart, resúmenes de bibliotecas por ABI y procedencia de compilación del motor | Ninguna (análisis APK estático); [guía de Flutter](docs/flutter-analysis.md) | | Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](docs/firmware-analysis.md) | | Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](docs/javascript-artifact-reconstruction.md), [aplicaciones de Apple](docs/apple-application-analysis.md) | | Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](docs/process-capture.md) | diff --git a/README_fa.md b/README_fa.md index 4a9885eef7..21ab30faeb 100644 --- a/README_fa.md +++ b/README_fa.md @@ -136,6 +136,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) | | پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) | | منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) | +| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) | | Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) | | بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) | | رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) | diff --git a/README_fr.md b/README_fr.md index c025cd14f9..5282fdc727 100644 --- a/README_fr.md +++ b/README_fr.md @@ -148,6 +148,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) | | Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](docs/jeb-analysis.md) | | Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](docs/apktool-resource-analysis.md) | +| Applications Flutter | Empreinte de snapshot Dart, condensés de bibliothèques par ABI et provenance de build du moteur | Aucune (analyse APK statique) ; [guide Flutter](docs/flutter-analysis.md) | | Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](docs/firmware-analysis.md) | | Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](docs/javascript-artifact-reconstruction.md), [applications Apple](docs/apple-application-analysis.md) | | Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](docs/process-capture.md) | diff --git a/README_id.md b/README_id.md index 0ca8713121..7dc588fa0f 100644 --- a/README_id.md +++ b/README_id.md @@ -151,6 +151,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) | | Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](docs/jeb-analysis.md) | | Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](docs/apktool-resource-analysis.md) | +| Aplikasi Flutter | Hash snapshot Dart, hash pustaka per ABI, dan asal build engine | Tidak ada (parsing APK statis); [panduan Flutter](docs/flutter-analysis.md) | | Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) | | Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) | | Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) | diff --git a/README_it.md b/README_it.md index af908746d4..fe9a6eb764 100644 --- a/README_it.md +++ b/README_it.md @@ -148,6 +148,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) | | Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](docs/jeb-analysis.md) | | Risorse Android | Manifest decodificato, tabelle stringhe, lingue e dati di versione | Launcher Apktool fornito dal chiamante; [guida alle risorse](docs/apktool-resource-analysis.md) | +| App Flutter | Hash dello snapshot Dart, digest delle librerie per ABI e provenienza della build del motore | Nessuno (analisi APK statica); [guida Flutter](docs/flutter-analysis.md) | | Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](docs/firmware-analysis.md) | | Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](docs/javascript-artifact-reconstruction.md), [Applicazioni Apple](docs/apple-application-analysis.md) | | Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](docs/process-capture.md) | diff --git a/README_ja.md b/README_ja.md index 370fb591d8..607315297a 100644 --- a/README_ja.md +++ b/README_ja.md @@ -144,6 +144,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) | | JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](docs/jeb-analysis.md) | | Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](docs/apktool-resource-analysis.md) | +| Flutter アプリ | Dart スナップショットハッシュ、ABI ごとのライブラリダイジェスト、エンジンビルドの来歴 | 不要(静的 APK 解析);[Flutter ガイド](docs/flutter-analysis.md) | | ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) | | パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) | | プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) | diff --git a/README_ko.md b/README_ko.md index 70301cfbcb..d2df0703c7 100644 --- a/README_ko.md +++ b/README_ko.md @@ -144,6 +144,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) | | JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](docs/jeb-analysis.md) | | Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](docs/apktool-resource-analysis.md) | +| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](docs/flutter-analysis.md) | | 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) | | 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) | | 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) | diff --git a/README_pl.md b/README_pl.md index f3db2fbd2d..30ea43edaa 100644 --- a/README_pl.md +++ b/README_pl.md @@ -144,6 +144,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) | | Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](docs/jeb-analysis.md) | | Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](docs/apktool-resource-analysis.md) | +| Aplikacje Flutter | Hash migawki Dart, sumy bibliotek per ABI i pochodzenie kompilacji silnika | Brak (statyczna analiza APK); [przewodnik Flutter](docs/flutter-analysis.md) | | Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](docs/firmware-analysis.md) | | Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](docs/javascript-artifact-reconstruction.md), [aplikacje Apple](docs/apple-application-analysis.md) | | Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](docs/process-capture.md) | diff --git a/README_pt-BR.md b/README_pt-BR.md index 662225a218..a84f6e0db6 100644 --- a/README_pt-BR.md +++ b/README_pt-BR.md @@ -144,6 +144,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) | | Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](docs/jeb-analysis.md) | | Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](docs/apktool-resource-analysis.md) | +| Aplicativos Flutter | Hash de snapshot Dart, hashes de bibliotecas por ABI e procedência de build do motor | Nenhuma (análise estática de APK); [guia Flutter](docs/flutter-analysis.md) | | Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](docs/firmware-analysis.md) | | Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](docs/javascript-artifact-reconstruction.md), [aplicativos Apple](docs/apple-application-analysis.md) | | Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](docs/process-capture.md) | diff --git a/README_ru.md b/README_ru.md index a22c620ce3..914ef09da3 100644 --- a/README_ru.md +++ b/README_ru.md @@ -144,6 +144,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) | | Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) | | Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) | +| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) | | Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) | | Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) | | Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) | diff --git a/README_th.md b/README_th.md index 7f87a81cf0..2abe5a0782 100644 --- a/README_th.md +++ b/README_th.md @@ -144,6 +144,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) | | โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](docs/jeb-analysis.md) | | ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](docs/apktool-resource-analysis.md) | +| แอป Flutter | แฮชสแนปชอต Dart, แฮชไลบรารีราย ABI และที่มาของบิลด์เอนจิน | ไม่ต้อง (วิเคราะห์ APK แบบ static); [คู่มือ Flutter](docs/flutter-analysis.md) | | เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](docs/firmware-analysis.md) | | แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](docs/javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](docs/apple-application-analysis.md) | | พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](docs/process-capture.md) | diff --git a/README_tr.md b/README_tr.md index abc40f0880..28caee2602 100644 --- a/README_tr.md +++ b/README_tr.md @@ -144,6 +144,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) | | JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](docs/jeb-analysis.md) | | Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](docs/apktool-resource-analysis.md) | +| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](docs/flutter-analysis.md) | | Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](docs/firmware-analysis.md) | | Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](docs/javascript-artifact-reconstruction.md), [Apple uygulamaları](docs/apple-application-analysis.md) | | Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](docs/process-capture.md) | diff --git a/README_uk.md b/README_uk.md index bf824a4f1e..a97624ec8d 100644 --- a/README_uk.md +++ b/README_uk.md @@ -144,6 +144,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) | | Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](docs/jeb-analysis.md) | | Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](docs/apktool-resource-analysis.md) | +| Додатки Flutter | Хеш знімка Dart, дайджести бібліотек за ABI та походження збірки рушія | Не потрібні (статичний розбір APK); [підручник Flutter](docs/flutter-analysis.md) | | Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](docs/firmware-analysis.md) | | Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](docs/javascript-artifact-reconstruction.md), [застосунки Apple](docs/apple-application-analysis.md) | | Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](docs/process-capture.md) | diff --git a/README_vi.md b/README_vi.md index 9bb23d09a8..be4eac7c25 100644 --- a/README_vi.md +++ b/README_vi.md @@ -144,6 +144,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) | | Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](docs/jeb-analysis.md) | | Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](docs/apktool-resource-analysis.md) | +| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](docs/flutter-analysis.md) | | Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](docs/firmware-analysis.md) | | Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](docs/javascript-artifact-reconstruction.md), [ứng dụng Apple](docs/apple-application-analysis.md) | | Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](docs/process-capture.md) | diff --git a/README_zh-TW.md b/README_zh-TW.md index 6c80ce6f2d..841e4d4795 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) | | JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](docs/jeb-analysis.md) | | Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](docs/apktool-resource-analysis.md) | +| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](docs/firmware-analysis.md) | | 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 應用](docs/apple-application-analysis.md) | | 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](docs/process-capture.md) | diff --git a/README_zh.md b/README_zh.md index e47aac3bcf..3d079c9704 100644 --- a/README_zh.md +++ b/README_zh.md @@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) | | JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](docs/jeb-analysis.md) | | Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](docs/apktool-resource-analysis.md) | +| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) | | 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) | | 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) | diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md new file mode 100644 index 0000000000..75c23dd661 --- /dev/null +++ b/docs/flutter-analysis.md @@ -0,0 +1,156 @@ +# Flutter build identification + +REA identifies a Flutter app's build facts by **pure parsing** of its APK: +the Dart VM snapshot hash, per-ABI library digests, the engine's GNU +build-id, and any labeled version strings. No engine, no tool installation, +no execution, and no external lookups — this family answers "which Dart SDK +build is this, and what exactly is in the payload?" before any deeper +native analysis. + +This is the triage step for Flutter reversing: the snapshot hash is the +artifact that maps to a Dart SDK release through an external dataset (such +as the one reFlutter maintains), and knowing the SDK version is what makes +SDK-layout-specific snapshot parsers applicable. + +## Answer an analyst question + +```sh +rea identify-flutter-build /targets/Example.apk +rea inspect-dart-aot /targets/Example.apk +rea inspect-dart-aot /targets/Example.apk --abi armeabi-v7a +``` + +The equivalent MCP methods are `identify_flutter_build` (with `path`) and +`inspect_dart_aot` (with `path` and an optional `abi`; the default prefers +arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining +ABIs alphabetically). + +## What the AOT inspection returns + +`inspect_dart_aot` reads one ABI's `libapp.so` in full and reports: + +- **Snapshot sections** — the four `_kDart*Snapshot*` symbols located + through the ELF **dynamic symbol table** (ELF32 and ELF64, little-endian, + every read bounds-checked), each with file offset, size, and — for data + sections, which carry the `f5f5dcdc` magic — the parsed header kind and + length (the stored length excludes the four magic bytes). Data headers + whose declared snapshot length does not fit their symbol retain their + observed fields and report partial coverage. Complete structural coverage + currently requires kind 3, the FullAOT value in the supported + [Dart 2.14 layout](https://github.com/dart-lang/sdk/blob/2.14.0/runtime/vm/snapshot.h). + Kind numbers vary across Dart releases; other values are preserved with + partial coverage, without guessing their interpretation. + A payload without exported snapshot symbols is refused rather + than guessed at. +- **The snapshot hash** from the header at the start of each exported data + symbol. Complete hash coverage requires a valid header from both the VM + and isolate; duplicate records inside one symbol cannot stand in for the + other's header. Aliased or overlapping data symbols report partial coverage. +- **A string-pool projection from the isolate data section**: distinct + `package:` source URIs (the app's own and its dependencies' Dart files — + a dependency inventory), `dart:` SDK URIs (which SDK libraries the app + uses), and identifier-shaped tokens, each bounded with counts and + `coverage: "partial"` when truncated. On the reference target this counts + 756 package URIs and 201 dart URIs on arm64-v8a and x86_64, and 212 / 48 + on armeabi-v7a, retaining up to 500 of each in the response. + `libapp.coverage` describes structural snapshot checks; + `string_pool.coverage` describes the bounded projection. Overall coverage + is partial when either facet is partial. + +## Interpret the result + +- **`flutter_detected`** is true when any `lib//libapp.so` exists. A + negative answer reports complete coverage: absence is an answer. +- **`snapshot_hash`** is the Dart build hash observed 20 bytes after the + snapshot magic (`f5 f5 dc dc`) inside `libapp.so`. Identification scans + byte patterns; AOT inspection reads the exported VM and isolate headers. + `snapshot_hash_sources` counts the valid records observed by the chosen + operation, and when they **disagree** the distinct values are + kept in `snapshot_hash_candidates` with `snapshot_hash: null` and + `coverage: "partial"` rather than silently picking one. +- **`elf_header_valid`** checks the supported little-endian ELF32/ELF64 + header, table entry widths, and full table extents against the library's + actual byte length. Truncated, malformed, or unsupported headers report + false and partial coverage while retaining byte-scanned hashes. This + header check does not establish valid table contents or exported symbols. +- **`libflutter` facts** are per-ABI observations: byte size, SHA-256 + digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges, + and any _labeled_ strings the build + carries. Release engine builds often strip the `Dart VM version` string — + absence is reported as `null`, not guessed. Android clang/LLVM toolchain + lines are reported verbatim as build provenance. + `build_id_candidates` preserves distinct IDs. `build_id` is populated only + when all declared note ranges were examined and one ID was found; + conflicting IDs or malformed/bounded note metadata make coverage partial. + Matching bytes outside declared note ranges cannot supply a build ID. +- Labeled strings must fit their complete grammar. `dart_version_capped` + and `toolchain_capped` independently report labels omitted by grammar or + projection bounds; either makes identification coverage partial. Values + split across read chunks are retained without publishing truncated prefixes. +- **`coverage: "partial"`** also appears when an ABI carries `libapp.so` + without `libflutter.so` (or vice versa). +- The APK itself is digested before parsing, so `target.sha256` binds the + observation to the exact input. + +## Limits + +- **Owned snapshots.** Both operations copy the admitted APK into a + provider-owned, read-only snapshot before digesting and parsing, so a + concurrently modified original cannot mix into one observation; failed + reader closure and snapshot cleanup are retained and retried on `close()`. + The snapshot is removed only after all owned readers and file descriptors + close successfully. Copying pins one admitted descriptor and compares its + identity, size, mode and timestamps after the copy. Source permission + failures remain `access_denied`, and source identity changes remain + `artifact_changed`. Provider-owned storage failures report a + `resource_constraint` with their native I/O cause. Cancellation is checked + during copying, metadata/symbol walks, string scanning, and after cleanup. +- **Bounded reads.** APK snapshots are bounded at 2 GiB, including while + copying. Identification permits 768 MiB of cumulative library extraction; + declared sizes are checked before extraction. Library reads are bounded at 256 MiB each and ZIP + metadata at 8 MiB; the AOT image buffer is allocated at exactly the + declared size before reading. Exceeded budgets report + `resource_constraint`. +- **Engine notes.** Engine libraries are spooled into the owned root. ELF + headers, note tables and note payloads share an 8 MiB metadata budget; + up to 64 distinct build IDs are retained. Exceeding either bound preserves + the candidates already examined and reports partial coverage. +- **Metadata and candidate capacity.** Both operations refuse duplicate + relevant library paths. Inventory is limited to 32 ABIs and 1024 bytes per + relevant library path, independently of compressed size. Identification + refuses more than 64 distinct snapshot hashes with `resource_constraint` + rather than silently dropping conflicting evidence. +- **Selected ABI.** AOT inspection applies the library budget to the selected + ABI after selection; an oversized unselected library does not block it. + Undefined dynamic-symbol imports are not treated as snapshot exports. + Section-stripped images require a bounded symbol count from `DT_HASH` or + `DT_GNU_HASH`; layouts without either are refused. Symbol names and their + terminators must fit their linked string table. +- REA performs **no hash-to-SDK lookup**: mapping the snapshot hash to a + Dart SDK release requires an external dataset and is deliberately out of + scope for this family. +- The AOT string pool is a **byte-pattern projection, not deserialized + cluster semantics**: typed class and function recovery requires an + SDK-specific parser (the snapshot layout changes per release — exactly + why the hash is the key), and `class_like_tokens` include incidental + matches. JEB's GUI Dart unit and native analysis of `libapp.so` cover + some ground today. + +## Real verification + +`npm run verify:flutter -- --apk PATH [--plain-apk PATH]` runs both tools +through compiled CLI and stdio MCP, compares Evidence, and checks every ABI. +Use `--expectations scripts/fixtures/flutter-gallery-expectations.json` with +the Gallery 2.9.2 release APK to check exact per-ABI engine labels, +URI counts and the snapshot hash against its pinned SHA-256. The verifier +requires complete structural snapshot coverage on every ABI and explicitly +reports the bounded pool projection as partial/unverified when truncated. +Other APKs need their own expectations file to verify exact engine labels +and URI counts; otherwise those facets are reported unverified. +The optional plain APK checks negative detection. Record: Flutter Gallery 2.9.2 (112 MB, three +ABIs) — every ABI reports the same agreeing hash +`65817c30a78bb44c3dc3771876b6010a` from two snapshot sections, per-ABI +engine build-ids, no labeled Dart version (as expected for a release +engine), and the Android clang toolchain line; a plain aapt2-built APK +reports not-detected with complete coverage. The suite uses self-contained +synthetic ELF images and scanner payloads; it does not read private local files. diff --git a/docs/testing.md b/docs/testing.md index ec065b14d7..d0748b7605 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -497,6 +497,36 @@ with the reported target identity, apktool.yml metadata projection [Apktool resource analysis](apktool-resource-analysis.md) for boundaries and budgets. +## Flutter build identification + +`npm run verify:flutter -- --apk PATH [--plain-apk PATH]` runs both pure +static operations against a real Flutter APK, plus an optional non-Flutter +APK for the negative check. No engine or external tool is involved. Record: +Flutter Gallery 2.9.2 (112 MB, arm64-v8a / armeabi-v7a / x86_64) — every +ABI reports the same agreeing snapshot hash +(`65817c30a78bb44c3dc3771876b6010a`, two sections each) read 20 bytes after +the Dart snapshot magic, per-ABI engine GNU build-ids, no labeled Dart +version string (expected for a release engine build), and the Android +clang/LLVM toolchain line; the AOT inspection locates all four snapshot +symbols through the dynamic symbol table on both ELF64 and ELF32 images, +validates the data-section headers, agrees with the identification hash, +and counts 756 `package:` and 201 `dart:` URIs on arm64-v8a and x86_64, +and 212 / 48 on armeabi-v7a, from each isolate data section. Responses +retain at most 500 URIs per family and 2000 class-like tokens. Gallery's +pool projection and overall AOT coverage are partial while its structural +`libapp.coverage` is complete. A plain aapt2-built APK reports not-detected with +complete coverage. The suite uses self-contained synthetic ELF images and recorded scanner +prefixes, with no private local-file dependency. The verifier now compares +compiled CLI and stdio MCP Evidence on every ABI, including data-header kind +and declared lengths. Add +`--expectations scripts/fixtures/flutter-gallery-expectations.json` for the +documented Gallery APK to assert its pinned digest, hash, exact engine labels +and per-ABI URI counts. The verifier requires complete structural snapshot +coverage, reports bounded pool coverage as partial/unverified, and reports +exact labels/counts on other APKs unverified without an expectations file. +See [Flutter build identification](flutter-analysis.md) for boundaries and +budgets. + ## ADB device acquisition `npm run verify:adb` exercises every ADB operation against a live device or diff --git a/package.json b/package.json index 0393f84c37..ea47ce8509 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs", "verify:architecture-guards": "node scripts/verify/architecture-guards.mjs", "verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs", + "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", diff --git a/scripts/fixtures/flutter-gallery-expectations.json b/scripts/fixtures/flutter-gallery-expectations.json new file mode 100644 index 0000000000..909d86e661 --- /dev/null +++ b/scripts/fixtures/flutter-gallery-expectations.json @@ -0,0 +1,38 @@ +{ + "target_sha256": "659b5fc6a4319dfe4f117d00ad4e7ec004915ebcadcb96628797fb3544040e29", + "snapshot_hash": "65817c30a78bb44c3dc3771876b6010a", + "engines": { + "x86_64": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + }, + "armeabi-v7a": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + }, + "arm64-v8a": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + } + }, + "string_pools": { + "x86_64": { + "package_uri_count_at_least": 756, + "dart_uri_count_at_least": 201 + }, + "armeabi-v7a": { + "package_uri_count_at_least": 212, + "dart_uri_count_at_least": 48 + }, + "arm64-v8a": { + "package_uri_count_at_least": 756, + "dart_uri_count_at_least": 201 + } + } +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 787d6255d6..2ecafd9ae9 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -49,6 +49,11 @@ export const toolFamilyCatalog = (sources) => { surface: "android-provider", contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + id: "flutter", + surface: "flutter-provider", + contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS, + }, { id: "frida", surface: "instrumentation-provider", @@ -231,6 +236,10 @@ export const providerCatalog = (sources) => { identity: sources.androidProvider.JADX_PROVIDER_IDENTITY, contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + identity: sources.flutterProvider.FLUTTER_PROVIDER_IDENTITY, + contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS, + }, { identity: sources.apktoolProvider.APKTOOL_PROVIDER_IDENTITY, contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 546b7359c4..6287f257b7 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -58,8 +58,12 @@ const SOURCE_PATHS = { firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js", firmwareProvider: "dist/firmware/FirmwareRelease.js", androidContracts: "dist/contracts/android/androidToolContracts.js", + flutterContracts: "dist/contracts/flutter/flutterToolContracts.js", + flutterProvider: "dist/flutter/FlutterBuildProvider.js", androidProvider: "dist/android/JadxRelease.js", apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js", + flutterContracts: "dist/contracts/flutter/flutterToolContracts.js", + flutterProvider: "dist/flutter/FlutterBuildProvider.js", apktoolProvider: "dist/apktool/ApktoolProvider.js", jebContracts: "dist/contracts/jeb/jebToolContracts.js", jebProvider: "dist/jeb/JebProvider.js", diff --git a/scripts/verify-flutter.mjs b/scripts/verify-flutter.mjs new file mode 100644 index 0000000000..061ad8b20e --- /dev/null +++ b/scripts/verify-flutter.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { toolContract } from "../dist/contracts/toolContracts.js"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { hashStableFile } from "../dist/filesystem/StableFileHash.js"; +import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; + +const options = parseArguments(process.argv.slice(2)); +if (options.help) { + process.stdout.write( + "Usage: npm run verify:flutter -- --apk PATH [--plain-apk PATH] [--expectations JSON]\n" + + "Checks real APK parsing through compiled CLI and stdio MCP. Optional\n" + + "expectations bind exact engine labels, URI counts and snapshot hash to an APK digest.\n", + ); + process.exit(0); +} +if (options.apk === null) { + process.stderr.write("No Flutter APK was provided; pass --apk PATH.\n"); + process.exit(2); +} +const expectations = + options.expectations === null + ? null + : JSON.parse(await readFile(options.expectations, "utf8")); +const root = await PrivateRuntimeRoot.create({ + prefix: "rea-flutter-verifier-", +}); +const entrypoint = resolve("scripts/rea.mjs"); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => typeof value === "string"), +); +Object.assign(environment, { + TMPDIR: root.path, + TMP: root.path, + TEMP: root.path, +}); +const client = new Client({ name: "flutter-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const execute = promisify(execFile); +try { + await client.connect(transport); + const apk = resolve(options.apk); + const identity = await hashStableFile(apk); + const identified = await run("identify_flutter_build", { path: apk }); + assert.deepEqual(identified.target, { path: apk, ...identity }); + assert.equal(identified.flutter_detected, true); + assert.equal(identified.coverage, "complete"); + const abis = identified.abis.filter((abi) => abi.libapp.present); + assert.ok(abis.length > 0); + const hashes = new Set(); + for (const abi of abis) { + assert.equal(abi.libapp.elf_header_valid, true); + assert.equal(abi.libapp.snapshot_hash_sources, 2); + assert.ok(abi.libapp.snapshot_hash); + hashes.add(abi.libapp.snapshot_hash); + assert.equal(abi.libflutter.present, true); + assert.match(abi.libflutter.build_id ?? "", /^(?:[0-9a-f]{2}){4,64}$/u); + assert.deepEqual(abi.libflutter.build_id_candidates, [ + abi.libflutter.build_id, + ]); + } + assert.equal(hashes.size, 1, "Every ABI must agree on the snapshot hash"); + if (expectations !== null) { + assert.equal(identity.sha256, expectations.target_sha256); + assert.deepEqual([...hashes], [expectations.snapshot_hash]); + assert.deepEqual( + abis.map((abi) => abi.abi).sort(), + Object.keys(expectations.engines).sort(), + ); + for (const abi of abis) { + assert.deepEqual( + { + dart_version: abi.libflutter.dart_version, + toolchain_lines: abi.libflutter.toolchain_lines, + }, + expectations.engines[abi.abi], + ); + assert.equal(abi.libflutter.dart_version_capped, false); + assert.equal(abi.libflutter.toolchain_capped, false); + } + process.stdout.write( + "[ok] Exact per-ABI engine labels match digest-bound expectations\n", + ); + } else { + process.stdout.write( + "[unverified] Exact engine labels and their absence need --expectations JSON\n", + ); + } + // The same checks apply to the default selection and each remaining ABI. + const preferred = await run("inspect_dart_aot", { path: apk }); + for (const abi of abis) { + const aot = + abi.abi === preferred.abi + ? preferred + : await run("inspect_dart_aot", { path: apk, abi: abi.abi }); + assert.equal(aot.abi, abi.abi); + assert.equal( + aot.libapp.coverage, + "complete", + `Snapshot decoding must be complete on ${abi.abi}`, + ); + assert.equal(aot.coverage, aot.string_pool.coverage); + if (aot.coverage !== "complete") { + process.stdout.write( + `[unverified] ${abi.abi}: full string-pool projection (returned pool is bounded and partial)\n`, + ); + } + assert.deepEqual(aot.target, identified.target); + assert.equal(aot.libapp.sha256, abi.libapp.sha256); + assert.equal(aot.libapp.bytes, abi.libapp.bytes); + assert.equal(aot.libapp.snapshot_hash_sources, 2); + assert.equal(aot.libapp.snapshot_hash, abi.libapp.snapshot_hash); + assert.deepEqual( + aot.libapp.sections.map((section) => section.name).sort(), + [ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ].sort(), + ); + for (const section of aot.libapp.sections) { + assert.ok(section.offset + section.size <= aot.libapp.bytes); + if (!section.name.endsWith("Data")) continue; + assert.equal(section.magic_valid, true); + assert.equal( + section.kind, + 3, + "This verifier requires an AOT app snapshot", + ); + assert.ok( + section.header_length >= 48 && + section.header_length + 4 <= section.size, + `Snapshot extent must fit ${section.name} on ${abi.abi}`, + ); + } + assert.ok(aot.string_pool.package_uri_count_at_least > 0); + assert.ok(aot.string_pool.dart_uri_count_at_least > 0); + if (expectations?.string_pools !== undefined) { + const expected = expectations.string_pools[abi.abi]; + assert.ok( + expected, + `Pinned string-pool counts are required on ${abi.abi}`, + ); + assert.equal( + aot.string_pool.package_uri_count_at_least, + expected.package_uri_count_at_least, + ); + assert.equal( + aot.string_pool.dart_uri_count_at_least, + expected.dart_uri_count_at_least, + ); + process.stdout.write( + `[ok] ${abi.abi}: exact pinned package and Dart URI counts\n`, + ); + } else { + process.stdout.write( + `[unverified] ${abi.abi}: exact string-pool counts need --expectations JSON\n`, + ); + } + process.stdout.write( + `[ok] ${abi.abi}: four symbols, bounded AOT headers, agreeing hashes and string pool\n`, + ); + } + if (options.plainApk !== null) { + const plain = resolve(options.plainApk); + const negative = await run("identify_flutter_build", { path: plain }); + assert.deepEqual(negative.target, { + path: plain, + ...(await hashStableFile(plain)), + }); + assert.equal(negative.flutter_detected, false); + assert.equal(negative.coverage, "complete"); + } + assert.deepEqual( + await hashStableFile(apk), + identity, + "Verification must preserve its input", + ); +} finally { + // Server shutdown retries retained provider cleanup and must settle before success. + try { + await client.close(); + await transport.close(); + assert.deepEqual( + await readdir(root.path), + [], + "Provider snapshots must be released before verification succeeds", + ); + } finally { + await root.close(); + } +} +process.stdout.write( + "all asserted Flutter CLI/MCP checks passed; unverified facets are reported above\n", +); + +async function run(operation, input) { + const contract = toolContract(operation); + const args = [ + entrypoint, + operation.replaceAll("_", "-"), + input.path, + "--json", + ]; + if (input.abi !== undefined) args.push("--abi", input.abi); + const { stdout } = await execute(process.execPath, args, { + env: environment, + timeout: 60_000, + maxBuffer: 8 * 1024 * 1024, + }); + const cli = contract.outputSchema.parse(JSON.parse(stdout)); + const response = await client.callTool( + { name: operation, arguments: input }, + { timeout: 60_000 }, + ); + assert.notEqual(response.isError, true, mcpTextValue(response)); + const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response))); + assert.deepEqual(cli.normalized_result, mcp.normalized_result); + assert.deepEqual(cli.subject, mcp.subject); + assert.equal(cli.subject.local_path, input.path); + assert.equal(cli.subject.digest.sha256, cli.normalized_result.target.sha256); + assert.deepEqual(cli.limitations, mcp.limitations); + process.stdout.write( + `[ok] ${operation}${input.abi === undefined ? "" : ` ${input.abi}`}: CLI/MCP Evidence parity\n`, + ); + return cli.normalized_result; +} + +function parseArguments(argv) { + const parsed = { help: false, apk: null, plainApk: null, expectations: null }; + const names = { + "--apk": "apk", + "--plain-apk": "plainApk", + "--expectations": "expectations", + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") parsed.help = true; + else if ( + Object.hasOwn(names, argument) && + typeof argv[index + 1] === "string" && + !argv[index + 1].startsWith("--") + ) { + parsed[names[argument]] = argv[++index]; + } else throw new Error(`Unknown argument or missing value: ${argument}`); + } + return parsed; +} diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 76c5cafbb8..ac825bc049 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -453,6 +453,8 @@ const workflowAvailabilityFor = ({ remediation: "Run process capture on a supported Linux or macOS host.", }; if (kind === "application") return { reason: "available", remediation: null }; + if (kind === "flutter-provider") + return { reason: "available", remediation: null }; const electron = electronProviderAvailability(name, kind, policy); if (electron !== null) return electron; if (kind === "runtime-provider") diff --git a/src/application/flutter/FlutterBuildAnalysisPort.ts b/src/application/flutter/FlutterBuildAnalysisPort.ts new file mode 100644 index 0000000000..2a52d060ef --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisPort.ts @@ -0,0 +1,23 @@ +import type { + AnalysisExecution, + ExecutionOptions, + ProviderAvailability, +} from "../AnalysisProvider.js"; +import type { FlutterRequest } from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; + +/** + * Flutter build-identification boundary by pure APK parsing. + * Implementation details belong to the provider adapter. + */ +export interface FlutterBuildAnalysisPort { + /** Pure parsing; always available. */ + inspectAvailability(signal?: AbortSignal): Promise; + /** The provider holds no long-lived resources; safe to call always. */ + close(): Promise; + execute( + request: FlutterRequest, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/flutter/FlutterBuildAnalysisService.test.ts b/src/application/flutter/FlutterBuildAnalysisService.test.ts new file mode 100644 index 0000000000..1ecf31483a --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisService.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from "vitest"; + +import { FlutterBuildAnalysisService } from "./FlutterBuildAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "./FlutterBuildAnalysisPort.js"; +import { createAnalysisExecution } from "../AnalysisProvider.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { ok } from "../../domain/result.js"; + +const identity = { + id: "flutter", + name: "Flutter build identification", + version: null, +}; + +const execution = createAnalysisExecution( + { + target: { path: "/targets/app.apk", bytes: 10, sha256: "a".repeat(64) }, + flutter_detected: true, + abis: [], + coverage: "complete", + }, + identity, +); + +const refusing: FlutterBuildAnalysisPort = { + async inspectAvailability() { + throw new Error("must not probe"); + }, + async close() {}, + async execute() { + throw new Error("must not execute"); + }, +}; + +const malformedSelections: readonly unknown[] = [ + { path: "/targets/app.apk", extra: true }, + { path: 7 }, + {}, + { path: "/targets/app.apk", abi: "not a locale" }, +]; +it.each(malformedSelections)("refuses %j at admission", async (input) => { + const settled = await new FlutterBuildAnalysisService(refusing).execute( + "identify_flutter_build", + input, + ); + expect(settled.ok).toBe(false); + if (settled.ok) return; + expect(settled.error._tag).toBe("AnalysisInputError"); +}); + +it("passes the validated request to the provider and seals Evidence", async () => { + const seen: unknown[] = []; + const recording: FlutterBuildAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + seen.push(request); + return ok(execution); + }, + }; + const result = await new FlutterBuildAnalysisService(recording).execute( + "identify_flutter_build", + { path: "/targets/app.apk" }, + ); + expect(seen).toEqual([ + { + operation: "identify_flutter_build", + input: { path: "/targets/app.apk" }, + }, + ]); + if (!result.ok) throw new Error(result.error.message); + const evidence = parseEvidence(result.value); + expect(evidence.provider).toEqual(identity); + expect(evidence.confidence).toBe("observed"); +}); + +it("settles an already-aborted call as cancellation before execution", async () => { + const controller = new AbortController(); + controller.abort(); + const settled = await new FlutterBuildAnalysisService(refusing).execute( + "identify_flutter_build", + { path: "/targets/app.apk" }, + { + signal: controller.signal, + }, + ); + expect(settled.ok).toBe(false); + if (settled.ok) return; + expect(settled.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/flutter/FlutterBuildAnalysisService.ts b/src/application/flutter/FlutterBuildAnalysisService.ts new file mode 100644 index 0000000000..13ddfbba4e --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisService.ts @@ -0,0 +1,31 @@ +import type { FlutterBuildAnalysisPort } from "./FlutterBuildAnalysisPort.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { + flutterRequestSchema, + type FlutterOperation, +} from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { executeProviderOperation } from "../ProviderOperationExecution.js"; +import type { Result } from "../../domain/result.js"; + +/** Shared CLI/MCP admission, execution, and Evidence composition for Flutter. */ +export class FlutterBuildAnalysisService { + constructor(readonly provider: FlutterBuildAnalysisPort) {} + + /** Run one caller-selected Flutter request and retain target identity inline. */ + execute( + operation: FlutterOperation, + input: unknown, + options?: ExecutionOptions, + ): Promise> { + return executeProviderOperation( + operation, + input, + options, + flutterRequestSchema, + this.provider, + "observed", + ); + } +} diff --git a/src/artifacts/RegularFileAdmission.test.ts b/src/artifacts/RegularFileAdmission.test.ts index 818e75ef17..17926799a1 100644 --- a/src/artifacts/RegularFileAdmission.test.ts +++ b/src/artifacts/RegularFileAdmission.test.ts @@ -1,4 +1,4 @@ -import type { Stats } from "node:fs"; +import type { BigIntStats, Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import { afterEach, expect, it, vi } from "vitest"; @@ -6,6 +6,7 @@ import { NonRegularFileReadError, openRegularFile, RegularFileAdmissionFailure, + sameRegularFileState, } from "../filesystem/RegularFile.js"; const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() })); @@ -66,3 +67,24 @@ it.each(["cancellation after open", "non-regular metadata", "stat failure"])( else expect(cause.cause).toBe(cancellation); }, ); + +it("compares available nanosecond timestamps without rounding away source changes", () => { + const before = { + dev: 1n, + ino: 2n, + size: 3n, + mode: 0o100400n, + mtimeMs: 10n, + ctimeMs: 20n, + mtimeNs: 10_000_001n, + ctimeNs: 20_000_001n, + isFile: () => true, + } as BigIntStats; + expect(sameRegularFileState(before, { ...before })).toBe(true); + expect( + sameRegularFileState(before, { ...before, mtimeNs: before.mtimeNs + 1n }), + ).toBe(false); + expect( + sameRegularFileState(before, { ...before, ctimeNs: before.ctimeNs + 1n }), + ).toBe(false); +}); diff --git a/src/cli.ts b/src/cli.ts index 0ad7613d89..36c05f6c98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,7 @@ import { registerManagedCommands } from "./cli/managedCommands.js"; import { registerFirmwareCommands } from "./cli/firmwareCommands.js"; import { registerAndroidCommands } from "./cli/androidCommands.js"; import { registerApktoolCommands } from "./cli/apktoolCommands.js"; +import { registerFlutterCommands } from "./cli/flutterCommands.js"; import { registerJebCommands } from "./cli/jebCommands.js"; import { registerAdbCommands } from "./cli/adbCommands.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; @@ -98,6 +99,7 @@ export const createCli = ( registerManagedCommands(cli, logger, analysis.runProviderAnalysis); registerAndroidCommands(cli, logger, environment); registerApktoolCommands(cli, logger, environment); + registerFlutterCommands(cli, logger); registerJebCommands(cli, logger, environment); registerAdbCommands(cli, logger, environment); registerFirmwareCommands(cli, logger, environment); diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts new file mode 100644 index 0000000000..a1be8e6487 --- /dev/null +++ b/src/cli/flutterCommands.ts @@ -0,0 +1,97 @@ +import { z } from "incur"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { FlutterOperation } from "../domain/flutter/flutterBuildAnalysis.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** One flutter command: CLI shape and the request it produces. */ +interface FlutterCommandSpec { + readonly command: string; + readonly description: string; + readonly argumentSchema: z.ZodObject; + readonly optionSchema?: z.ZodObject; + readonly operation: FlutterOperation; + readonly toInput: ( + arguments_: Record, + options: Record, + ) => unknown; +} + +/** Expose the same explicit Flutter requests through one-shot CLI commands. */ +export const registerFlutterCommands = ( + cli: CliInstance, + logger: Logger, + provider: FlutterBuildAnalysisPort = createFlutterBuildAnalysisProvider(), +): void => { + const specs: readonly FlutterCommandSpec[] = [ + { + command: CLI_COMMANDS.identifyFlutterBuild, + description: + "Identify a Flutter build's Dart snapshot hash and engine facts by pure APK parsing", + argumentSchema: z.object({ + path: z.string().min(1).describe("Local APK file to inspect"), + }), + operation: "identify_flutter_build", + toInput: (arguments_) => arguments_, + }, + { + command: CLI_COMMANDS.inspectDartAot, + description: + "Inspect one ABI's Dart AOT snapshot: sections, headers, and the string pool", + argumentSchema: z.object({ + path: z + .string() + .min(1) + .describe("Local APK carrying a Flutter payload"), + }), + optionSchema: z.object({ + abi: z + .string() + .min(2) + .max(16) + .optional() + .describe("ABI under lib/ to inspect; defaults to the preferred one"), + }), + operation: "inspect_dart_aot", + toInput: (arguments_, options) => ({ + path: arguments_.path, + ...(options.abi === undefined ? {} : { abi: options.abi }), + }), + }, + ]; + for (const spec of specs) { + cli.command(spec.command, { + description: spec.description, + ...(spec.optionSchema === undefined + ? { args: spec.argumentSchema } + : { args: spec.argumentSchema, options: spec.optionSchema }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, spec.command, async () => { + const settled = await new FlutterBuildAnalysisService( + provider, + ).execute(spec.operation, spec.toInput(args, options ?? {}), { + signal, + }); + try { + await provider.close(); + } catch (cause) { + if (cause instanceof AnalysisError) + return projectAnalysisError(cause); + throw cause; + } + return settled.ok + ? settled.value + : projectAnalysisError(settled.error); + }), + ), + }); + } +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 3994100af8..8fa0505f1b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -52,6 +52,8 @@ export const CLI_COMMANDS = Object.freeze({ inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", + identifyFlutterBuild: "identify-flutter-build", + inspectDartAot: "inspect-dart-aot", inspectApktoolClient: "inspect-apktool-client", decodeAndroidResources: "decode-android-resources", inspectAdbClient: "inspect-adb-client", diff --git a/src/composition/flutter.ts b/src/composition/flutter.ts new file mode 100644 index 0000000000..75013f77a8 --- /dev/null +++ b/src/composition/flutter.ts @@ -0,0 +1,6 @@ +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { FlutterBuildProvider } from "../flutter/FlutterBuildProvider.js"; + +/** Construct one Flutter provider; it needs no external tools. */ +export const createFlutterBuildAnalysisProvider = + (): FlutterBuildAnalysisPort => new FlutterBuildProvider(); diff --git a/src/contracts/flutter/flutterToolContracts.ts b/src/contracts/flutter/flutterToolContracts.ts new file mode 100644 index 0000000000..9a663d4921 --- /dev/null +++ b/src/contracts/flutter/flutterToolContracts.ts @@ -0,0 +1,45 @@ +import { + flutterInputSchemas, + flutterResultSchemas, +} from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "../toolEffects.js"; + +/** Flutter build identification by pure APK parsing. */ +export const FLUTTER_TOOL_CONTRACTS = [ + { + name: "identify_flutter_build", + ...toolContractMetadata("identify_flutter_build"), + description: + "Identify a Flutter app's build facts by pure parsing: the Dart VM snapshot hash from every lib//libapp.so AOT section (vm and isolate, cross-checked), per-ABI library digests, the engine library's GNU build-id, and any labeled Dart version or toolchain strings libflutter.so carries. The snapshot hash is the artifact that maps to a Dart SDK release through an external dataset; REA performs no lookup. No engine, no execution, no external tools.", + kind: "flutter-provider", + inputSchema: flutterInputSchemas.identify_flutter_build, + outputSchema: evidenceResultOf(flutterResultSchemas.identify_flutter_build), + examples: [ + { + title: "Identify a Flutter build", + input: { path: "/targets/Example.apk" }, + }, + ], + }, + { + name: "inspect_dart_aot", + ...toolContractMetadata("inspect_dart_aot"), + description: + "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. No engine, no execution.", + kind: "flutter-provider", + inputSchema: flutterInputSchemas.inspect_dart_aot, + outputSchema: evidenceResultOf(flutterResultSchemas.inspect_dart_aot), + examples: [ + { + title: "Inspect the preferred ABI's AOT snapshot", + input: { path: "/targets/Example.apk" }, + }, + { + title: "Inspect one ABI explicitly", + input: { path: "/targets/Example.apk", abi: "arm64-v8a" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index 832660d04c..0ba2ca321c 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -26,6 +26,7 @@ export const TOOL_KINDS = [ "managed-provider", "android-provider", "apktool-provider", + "flutter-provider", "jeb-provider", "adb-provider", "firmware-provider", diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index fb0b7d6608..8725f30408 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -9,6 +9,7 @@ import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js"; import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js"; import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js"; import { APKTOOL_TOOL_CONTRACTS } from "./apktool/apktoolToolContracts.js"; +import { FLUTTER_TOOL_CONTRACTS } from "./flutter/flutterToolContracts.js"; import { JEB_TOOL_CONTRACTS } from "./jeb/jebToolContracts.js"; import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managed/managedWorkflowToolContracts.js"; @@ -39,6 +40,7 @@ export const TOOL_CONTRACTS = [ ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...APKTOOL_TOOL_CONTRACTS, + ...FLUTTER_TOOL_CONTRACTS, ...JEB_TOOL_CONTRACTS, ...ADB_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index bc9632740b..151c7d5f49 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,9 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { "decodes bytecode in REA's bundled worker without executing it", inspect_wasm_artifact: "WABT validates and decompiles the module without executing it", + identify_flutter_build: + "identifies the build through execution-free APK parsing", + inspect_dart_aot: "inspects snapshot bytes without executing them", inspect_analysis_view: "reports retained WASM execution/network states without performing execution", inspect_managed_artifact: "reads metadata without executing target code", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 8169e10e90..11a268c0e9 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -167,6 +167,14 @@ export const TOOL_EFFECTS: Readonly> = { launchesProcess: true, writesFilesystem: true, }), + identify_flutter_build: effects({ + mutatesSession: true, + writesFilesystem: true, + }), + inspect_dart_aot: effects({ + mutatesSession: true, + writesFilesystem: true, + }), inspect_apktool_client: effects({ mutatesSession: true, launchesProcess: true, diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts new file mode 100644 index 0000000000..068149f68f --- /dev/null +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { digestSchema } from "../digests.js"; + +/** + * Caller intent for Flutter build identification. This family is pure + * parsing: it reads an APK's `lib//libapp.so` and `libflutter.so` + * directly and runs no engine, tool, or device. + */ +export const flutterInputSchemas = { + identify_flutter_build: z.strictObject({ + path: localPathStringSchema.describe("Local APK file to inspect"), + }), + inspect_dart_aot: z.strictObject({ + path: localPathStringSchema.describe( + "Local APK file carrying a Flutter AOT payload", + ), + abi: z + .string() + .min(2) + .max(16) + .regex( + /^[a-z0-9_\x2d]+$/u, + "ABI name as it appears under lib/, e.g. arm64-v8a", + ) + .optional() + .describe( + "ABI to inspect; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically", + ), + }), +} as const; + +/** Supported Flutter-backed operations. */ +export type FlutterOperation = keyof typeof flutterInputSchemas; +/** Validated Flutter requests carry their selected operation. */ +export type FlutterRequest = { + [Name in FlutterOperation]: { + operation: Name; + input: z.infer<(typeof flutterInputSchemas)[Name]>; + }; +}[FlutterOperation]; + +/** Validate operation and input together so their types remain correlated. */ +export const flutterRequestSchema = z.discriminatedUnion("operation", [ + z.strictObject({ + operation: z.literal("identify_flutter_build"), + input: flutterInputSchemas.identify_flutter_build, + }), + z.strictObject({ + operation: z.literal("inspect_dart_aot"), + input: flutterInputSchemas.inspect_dart_aot, + }), +]); + +/** One hardware ABI's Flutter payload inside the APK. */ +export const flutterResultSchemas = { + identify_flutter_build: z.strictObject({ + target: z.strictObject({ + path: localPathStringSchema, + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + flutter_detected: z.boolean(), + abis: z.array( + z.strictObject({ + abi: z.string().min(1), + libapp: z.strictObject({ + present: z.boolean(), + bytes: z.number().int().nonnegative().nullable(), + sha256: digestSchema.nullable(), + /** Supported ELF header with table extents inside the library; table contents are not validated. */ + elf_header_valid: z.boolean().nullable(), + /** Number of Dart snapshot sections found (vm + isolate). */ + snapshot_hash_sources: z.number().int().nonnegative(), + /** Distinct hashes found; more than one means the sections disagree. */ + snapshot_hash_candidates: z.array( + z.string().regex(/^[0-9a-f]{32}$/u), + ), + /** The single hash when every snapshot section agrees. */ + snapshot_hash: z + .string() + .regex(/^[0-9a-f]{32}$/u) + .nullable(), + }), + libflutter: z.strictObject({ + present: z.boolean(), + bytes: z.number().int().nonnegative().nullable(), + sha256: digestSchema.nullable(), + build_id: z + .string() + .regex(/^[0-9a-f]{8,128}$/u) + .nullable() + .describe( + "The single GNU build ID when ELF note coverage is complete and unambiguous", + ), + build_id_candidates: z + .array(z.string().regex(/^[0-9a-f]{8,128}$/u)) + .max(64) + .describe( + "Distinct GNU build IDs observed in ELF-owned note ranges; conflicting IDs remain explicit", + ), + dart_version: z + .string() + .nullable() + .describe( + "Labeled 'Dart VM version' string, when a build carries one", + ), + toolchain_lines: z.array(z.string().min(1)), + /** Dart labels omitted by grammar or single-version projection bounds. */ + dart_version_capped: z.boolean(), + /** Toolchain labels omitted by grammar or projection bounds. */ + toolchain_capped: z.boolean(), + }), + }), + ), + coverage: z.enum(["complete", "partial"]), + }), + inspect_dart_aot: z.strictObject({ + target: z.strictObject({ + path: localPathStringSchema, + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + abi: z.string().min(1), + libapp: z.strictObject({ + coverage: z + .enum(["complete", "partial"]) + .describe( + "Coverage of ELF symbols, independent data headers, AOT kind, extents and agreeing hashes", + ), + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + snapshot_hash: z + .string() + .regex(/^[0-9a-f]{32}$/u) + .nullable(), + /** Distinct hashes found; more than one means the sections disagree. */ + snapshot_hash_candidates: z.array(z.string().regex(/^[0-9a-f]{32}$/u)), + /** Distinct exported data headers with a valid hash at the header's fixed offset. */ + snapshot_hash_sources: z.number().int().nonnegative(), + /** Snapshot symbols read from the dynamic symbol table. */ + sections: z.array( + z.strictObject({ + name: z.string().min(1), + offset: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + magic_valid: z.boolean(), + kind: z.number().int().nonnegative().nullable(), + /** Raw declared snapshot length, excluding the four magic bytes. */ + header_length: z.number().int().nonnegative().nullable(), + }), + ), + }), + string_pool: z.strictObject({ + coverage: z + .enum(["complete", "partial"]) + .describe( + "Coverage of the bounded printable-run projection; independent of snapshot header validity", + ), + /** Distinct package: source URIs, sorted, bounded. */ + package_uris: z.array(z.string().min(1)), + package_uri_count_at_least: z.number().int().nonnegative(), + /** Distinct dart: SDK URIs, sorted, bounded. */ + dart_uris: z.array(z.string().min(1)), + dart_uri_count_at_least: z.number().int().nonnegative(), + /** Identifier-like tokens, bounded; includes incidental matches. */ + class_like_tokens: z.array(z.string().min(1)), + class_like_token_count_at_least: z.number().int().nonnegative(), + printable_run_count: z.number().int().nonnegative(), + }), + coverage: z.enum(["complete", "partial"]), + }), +} as const; diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index a5c2391cfc..a3037c7281 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -95,5 +95,8 @@ export const sameRegularFileState = ( expected.ino === observed.ino && expected.size === observed.size && expected.mode === observed.mode && - expected.mtimeMs === observed.mtimeMs && - expected.ctimeMs === observed.ctimeMs; + ("mtimeNs" in expected && "mtimeNs" in observed + ? expected.mtimeNs === observed.mtimeNs && + expected.ctimeNs === observed.ctimeNs + : expected.mtimeMs === observed.mtimeMs && + expected.ctimeMs === observed.ctimeMs); diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts new file mode 100644 index 0000000000..b2b777de36 --- /dev/null +++ b/src/flutter/DartElf.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { readElfSymbols, readSnapshotHeader } from "./DartElf.js"; +import { + REFERENCE_HASH, + buildElf64Image, + buildStrippedElf64Image, + snapshotSection, +} from "./FlutterBuild.fixture.js"; + +const readElfSymbol = async (bytes: Buffer, name: string) => { + const result = await readElfSymbols(bytes, [name]); + return result.symbols[0] === undefined + ? { failure: result.failures[0]! } + : { symbol: result.symbols[0] }; +}; + +/** + * Build a synthetic ELF64 little-endian image with one PT_LOAD segment + * and a dynamic symbol table naming the Dart snapshot sections. This keeps + * the golden tests self-contained instead of reading the author's disk. + */ +const GALLERY_HASH = REFERENCE_HASH; + +const imageWithSections = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, + ]); + const section = snapshotSection(GALLERY_HASH); + return Buffer.concat([ + elf, + Buffer.alloc(0x400 - elf.length), + section, + Buffer.alloc(0x800 - 0x400 - section.length), + section, + ]); +}; + +/** The same layout without section headers: symbols live in PT_DYNAMIC. */ +const strippedImageWithSections = (): Buffer => { + const elf = buildStrippedElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, + ]); + const section = snapshotSection(GALLERY_HASH); + return Buffer.concat([ + elf, + Buffer.alloc(0x400 - elf.length), + section, + Buffer.alloc(0x800 - 0x400 - section.length), + section, + ]); +}; + +describe("readElfSymbol", () => { + it("locates snapshot symbols in a synthetic ELF64 image", async () => { + const image = imageWithSections(); + const isolateData = await readElfSymbol(image, "_kDartIsolateSnapshotData"); + if ("failure" in isolateData) throw new Error(isolateData.failure.reason); + expect(isolateData.symbol.offset).toBe(0x800); + expect(isolateData.symbol.size).toBe(52); + const header = readSnapshotHeader(image, isolateData.symbol.offset); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("refuses a non-ELF image instead of guessing", async () => { + const result = await readElfSymbol(Buffer.from("not an elf at all"), "x"); + expect("failure" in result && result.failure.reason).toBe( + "not an ELF image", + ); + }); + + it("refuses an unknown symbol", async () => { + const result = await readElfSymbol(buildElf64Image([]), "_kDartAbsent"); + expect("failure" in result).toBe(true); + }); + + it("locates snapshot symbols in a section-stripped ET_DYN image", async () => { + const image = strippedImageWithSections(); + const isolateData = await readElfSymbol(image, "_kDartIsolateSnapshotData"); + if ("failure" in isolateData) throw new Error(isolateData.failure.reason); + expect(isolateData.symbol.offset).toBe(0x800); + expect(isolateData.symbol.size).toBe(52); + const header = readSnapshotHeader(image, isolateData.symbol.offset); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("refuses a stripped image whose PT_DYNAMIC carries no symbols", async () => { + // Drop the PT_DYNAMIC program header by leaving only PT_LOAD. + const image = strippedImageWithSections(); + image.writeUInt16LE(1, 0x38); // e_phnum + const result = await readElfSymbol(image, "_kDartVmSnapshotData"); + expect("failure" in result && result.failure.reason).toBe( + "symbol _kDartVmSnapshotData is not in the dynamic symbol table", + ); + }); + + it("keeps refusing a stripped image that is not ET_DYN", async () => { + const image = buildStrippedElf64Image([]); + image.writeUInt16LE(1, 0x10); // ET_REL: the dynamic fallback must not apply + const result = await readElfSymbol(image, "_kDartVmSnapshotData"); + expect("failure" in result && result.failure.reason).toBe( + "ELF section header table is missing or out of bounds", + ); + }); + + it.each([false, true])( + "ignores undefined entries with loadable values (stripped: %s)", + async (stripped) => { + const image = stripped + ? strippedImageWithSections() + : imageWithSections(); + const table = stripped + ? Number( + image.readBigUInt64LE( + Number(image.readBigUInt64LE(64 + 56 + 8)) + 8, + ), + ) + : Number( + image.readBigUInt64LE( + Number(image.readBigUInt64LE(0x28)) + 64 + 0x18, + ), + ); + for (const sectionIndex of [ + 0, + 0xfff2, + 0xffff, + ...(stripped ? [] : [0x1234]), + ]) { + image.writeUInt16LE(sectionIndex, table + 6); + expect( + await readElfSymbol(image, "_kDartVmSnapshotData"), + ).toHaveProperty("failure"); + expect( + await readElfSymbol(image, "_kDartIsolateSnapshotData"), + ).toMatchObject({ symbol: { offset: 0x800, size: 52 } }); + } + }, + ); +}); + +describe("ELF symbol-table ownership", () => { + it.each([false, true])( + "keeps a symbol terminator inside dynstr (stripped: %s)", + async (stripped) => { + const image = stripped + ? strippedImageWithSections() + : imageWithSections(); + const name = "_kDartVmSnapshotData"; + if (stripped) { + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + image.writeBigUInt64LE(BigInt(name.length + 1), dynamic + 2 * 16 + 8); // DT_STRSZ, leading NUL included. + } else { + const shoff = Number(image.readBigUInt64LE(0x28)); + image.writeBigUInt64LE(BigInt(name.length + 1), shoff + 2 * 64 + 0x20); + } + expect((await readElfSymbols(image, [name])).symbols).toEqual([]); + }, + ); + + it("uses DT_HASH's count when strings precede symbols instead of reading following payload", async () => { + const image = strippedImageWithSections(); + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + const symbols = Number(image.readBigUInt64LE(dynamic + 8)); + image.copy(image, 0x300, symbols, symbols + 48); + image.writeBigUInt64LE(0x300n, dynamic + 8); + const hash = Number(image.readBigUInt64LE(dynamic + 4 * 16 + 8)); + image.writeUInt32LE(1, hash + 4); // Only the VM entry belongs to dynsym. + const result = await readElfSymbols(image, [ + "_kDartVmSnapshotData", + "_kDartIsolateSnapshotData", + ]); + expect(result.symbols).toEqual([ + { name: "_kDartVmSnapshotData", offset: 0x400, size: 52 }, + ]); + expect(result.failures).toContainEqual({ + reason: + "symbol _kDartIsolateSnapshotData is not in the dynamic symbol table", + }); + image.writeBigUInt64LE(0x60000001n, dynamic + 4 * 16); // No recognized count table. + expect( + (await readElfSymbols(image, ["_kDartVmSnapshotData"])).failures, + ).toContainEqual({ + reason: "Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count", + }); + }); + + it("bounds GNU hash chains and resolves both requested symbols in one inventory", async () => { + const image = strippedImageWithSections(); + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + const hash = Number(image.readBigUInt64LE(dynamic + 4 * 16 + 8)); + image.writeBigUInt64LE(0x6ffffef5n, dynamic + 4 * 16); + image.fill(0, hash, hash + 32); + image.writeUInt32LE(1, hash); // nbuckets + image.writeUInt32LE(1, hash + 4); // symoffset + image.writeUInt32LE(1, hash + 8); // bloom words + image.writeUInt32LE(1, hash + 24); // bucket: last chain begins at symbol 1 + image.writeUInt32LE(1, hash + 28); // chain terminator; count is 2 + const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"]; + expect( + (await readElfSymbols(image, names)).symbols.map((symbol) => symbol.name), + ).toEqual(names); + image.writeUInt32LE(0xffffffff, hash + 24); + expect((await readElfSymbols(image, names)).symbols).toEqual([]); + }); +}); + +describe("readSnapshotHeader", () => { + it("reads a synthesized data header", async () => { + const header = readSnapshotHeader(snapshotSection(GALLERY_HASH), 0); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("reports nulls for sections without the data magic", async () => { + const header = readSnapshotHeader(Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), 0); + expect(header).toEqual({ + magicValid: false, + kind: null, + headerLength: null, + hash: null, + complete: false, + }); + }); +}); diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts new file mode 100644 index 0000000000..48285737df --- /dev/null +++ b/src/flutter/DartElf.ts @@ -0,0 +1,506 @@ +import { Buffer } from "node:buffer"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { + SNAPSHOT_HASH_LENGTH, + SNAPSHOT_HASH_OFFSET, + SNAPSHOT_HASH_PATTERN, +} from "./FlutterPayloadScan.js"; + +/** Bounds-checked little-endian reader over one loaded buffer. */ +class BoundedReader { + constructor(private readonly bytes: Buffer) {} + + u8(offset: number): number | null { + return offset < this.bytes.length ? this.bytes.readUInt8(offset) : null; + } + + u16(offset: number): number | null { + return offset + 2 <= this.bytes.length + ? this.bytes.readUInt16LE(offset) + : null; + } + + u32(offset: number): number | null { + return offset + 4 <= this.bytes.length + ? this.bytes.readUInt32LE(offset) + : null; + } + + u64(offset: number): number | null { + const low = this.u32(offset); + const high = this.u32(offset + 4); + if (low === null || high === null || high > 0x001fffff) return null; + return high * 0x1_0000_0000 + low; + } + + cString(offset: number, tableEnd: number): string | null { + if (offset < 0 || offset >= tableEnd || tableEnd > this.bytes.length) + return null; + const end = this.bytes + .subarray(offset, Math.min(tableEnd, offset + 257)) + .indexOf(0); + return end < 0 + ? null + : this.bytes.subarray(offset, offset + end).toString("latin1"); + } +} + +export interface ElfSymbol { + readonly name: string; + /** File offset mapped from the virtual address through PT_LOAD. */ + readonly offset: number; + readonly size: number; +} + +export interface ElfParseFailure { + readonly reason: string; +} + +const SHT_DYNSYM = 11; +const PT_LOAD = 1; +const PT_DYNAMIC = 2; +/** Position-independent executable images carry PT_DYNAMIC. */ +const ET_DYN = 3; +const DT_NULL = 0; +const DT_STRTAB = 5; +const DT_SYMTAB = 6; +const DT_STRSZ = 10; +const DT_SYMENT = 11; + +/** ELF32 needs 52 bytes, ELF64 needs 64; retain at most this prefix. */ +export const ELF_HEADER_MAX_BYTES = 64; + +/** + * Decode the supported ELF header and validate its table extents against + * the complete image size. The buffer may contain only the header prefix; + * this does not validate table contents or symbol availability. + */ +export const readElfHeader = ( + bytes: Buffer, + totalBytes: number = bytes.length, +) => { + const reader = new BoundedReader(bytes); + const magic = reader.u32(0); + if (magic !== 0x464c457f) return { failure: { reason: "not an ELF image" } }; + const elfClass = reader.u8(4); + if (elfClass !== 1 && elfClass !== 2) + return { failure: { reason: `unsupported ELF class ${String(elfClass)}` } }; + const is64 = elfClass === 2; + const headerBytes = is64 ? ELF_HEADER_MAX_BYTES : 52; + if ( + bytes.length < headerBytes || + !Number.isSafeInteger(totalBytes) || + totalBytes < headerBytes + ) + return { failure: { reason: "ELF header is truncated" } }; + if (reader.u8(5) !== 1) + return { failure: { reason: "unsupported ELF byte order" } }; + if (reader.u8(6) !== 1 || reader.u32(0x14) !== 1) + return { failure: { reason: "unsupported ELF version" } }; + if (reader.u16(is64 ? 0x34 : 0x28) !== headerBytes) + return { failure: { reason: "unsupported ELF header size" } }; + const e_type = reader.u16(0x10); + if (e_type !== 1 && e_type !== 2 && e_type !== ET_DYN) + return { failure: { reason: "unsupported ELF type" } }; + const e_shoff = is64 ? reader.u64(0x28) : reader.u32(0x20); + const e_shentsize = reader.u16(is64 ? 0x3a : 0x2e); + const e_shnum = reader.u16(is64 ? 0x3c : 0x30); + const e_phoff = is64 ? reader.u64(0x20) : reader.u32(0x1c); + const e_phentsize = reader.u16(is64 ? 0x36 : 0x2a); + const e_phnum = reader.u16(is64 ? 0x38 : 0x2c); + const tableFits = ( + offset: number | null, + width: number | null, + count: number | null, + minimumWidth: number, + ): boolean => + offset !== null && + width !== null && + count !== null && + offset >= headerBytes && + offset <= totalBytes && + width >= minimumWidth && + count > 0 && + count <= Math.floor((totalBytes - offset) / width); + const MIN_SHENT = is64 ? 64 : 40; + // Section-stripped ET_DYN images keep their exported symbols reachable + // through PT_DYNAMIC instead of section headers; every other shape still + // needs a usable section header table. + // A nonzero shoff with shnum=0 uses extended numbering, which this + // parser does not support; it must not masquerade as a stripped image. + const dynamicFallback = e_type === ET_DYN && e_shoff === 0 && e_shnum === 0; + if ( + e_shoff === null || + e_shentsize === null || + e_shnum === null || + (!dynamicFallback && !tableFits(e_shoff, e_shentsize, e_shnum, MIN_SHENT)) + ) + return { + failure: { + reason: "ELF section header table is missing or out of bounds", + }, + }; + const MIN_PHENT = is64 ? 56 : 32; + if ( + e_phoff === null || + e_phentsize === null || + e_phnum === null || + e_phnum === 0xffff || + !tableFits(e_phoff, e_phentsize, e_phnum, MIN_PHENT) + ) + return { + failure: { + reason: "ELF program header table is missing or out of bounds", + }, + }; + + return { + header: { + is64, + dynamicFallback, + e_shoff, + e_shentsize, + e_shnum, + e_phoff, + e_phentsize, + e_phnum, + }, + }; +}; + +export interface ElfSymbolInventory { + readonly symbols: readonly ElfSymbol[]; + readonly failures: readonly ElfParseFailure[]; +} + +/** Decode the ELF tables once and resolve only the requested definitions. */ +export const readElfSymbols = async ( + bytes: Buffer, + symbolNames: readonly string[], + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + const failure = (reason: string): ElfSymbolInventory => ({ + symbols: [], + failures: [{ reason }], + }); + const parsed = readElfHeader(bytes); + if (parsed.failure !== undefined) return failure(parsed.failure.reason); + const { + is64, + dynamicFallback, + e_shoff, + e_shentsize, + e_shnum, + e_phoff, + e_phentsize, + e_phnum, + } = parsed.header; + const reader = new BoundedReader(bytes); + const entrySize = is64 ? 24 : 16; + const segments: { vaddr: number; offset: number; filesz: number }[] = []; + let dynamicSegment: { offset: number; filesz: number } | null = null; + for (let index = 0; index < e_phnum; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = e_phoff + index * e_phentsize; + const type = reader.u32(base); + if (type !== PT_LOAD && type !== PT_DYNAMIC) continue; + const vaddr = is64 ? reader.u64(base + 0x10) : reader.u32(base + 8); + const offset = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + const filesz = is64 ? reader.u64(base + 0x20) : reader.u32(base + 0x10); + if ( + vaddr === null || + offset === null || + filesz === null || + offset > bytes.length || + filesz > bytes.length - offset + ) + return failure(`ELF segment ${String(index)} is not file-backed`); + if (type === PT_LOAD) segments.push({ vaddr, offset, filesz }); + else if (dynamicSegment !== null) + return failure("Multiple PT_DYNAMIC segments are ambiguous"); + else dynamicSegment = { offset, filesz }; + } + const toOffset = (vaddr: number, size: number): number | null => { + const segment = segments.find( + (candidate) => + vaddr >= candidate.vaddr && + vaddr - candidate.vaddr <= candidate.filesz && + size <= candidate.filesz - (vaddr - candidate.vaddr), + ); + return segment === undefined + ? null + : segment.offset + vaddr - segment.vaddr; + }; + if (segments.length === 0) + return failure("ELF image has no PT_LOAD segments"); + const tables: { + offset: number; + count: number; + stringOffset: number; + stringSize: number; + }[] = []; + if (!dynamicFallback) { + for (let index = 0; index < e_shnum; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const header = e_shoff + index * e_shentsize; + if (reader.u32(header + 4) !== SHT_DYNSYM) continue; + const offset = is64 + ? reader.u64(header + 0x18) + : reader.u32(header + 0x10); + const size = is64 ? reader.u64(header + 0x20) : reader.u32(header + 0x14); + const width = is64 + ? reader.u64(header + 0x38) + : reader.u32(header + 0x24); + const link = reader.u32(header + (is64 ? 0x28 : 0x18)); + if ( + offset === null || + size === null || + width !== entrySize || + offset > bytes.length || + size > bytes.length - offset || + size % entrySize !== 0 + ) + return failure("Dynamic symbol table is malformed"); + if (link === null || link >= e_shnum) + return failure("Dynamic symbol string section link is invalid"); + const stringHeader = e_shoff + link * e_shentsize; + const stringOffset = is64 + ? reader.u64(stringHeader + 0x18) + : reader.u32(stringHeader + 0x10); + const stringSize = is64 + ? reader.u64(stringHeader + 0x20) + : reader.u32(stringHeader + 0x14); + if ( + reader.u32(stringHeader + 4) !== 3 || + stringOffset === null || + stringSize === null || + stringOffset > bytes.length || + stringSize > bytes.length - stringOffset + ) + return failure( + "Dynamic symbol strings are out of bounds or not a string table", + ); + tables.push({ + offset, + count: size / entrySize, + stringOffset, + stringSize, + }); + } + } else if (dynamicSegment !== null) { + const tags = new Map(); + const dynamicEntrySize = is64 ? 16 : 8; + let terminated = false; + for ( + let base = dynamicSegment.offset; + base + dynamicEntrySize <= dynamicSegment.offset + dynamicSegment.filesz; + base += dynamicEntrySize + ) { + if ((base - dynamicSegment.offset) % (4096 * dynamicEntrySize) === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const tag = is64 ? reader.u64(base) : reader.u32(base); + const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + if (tag === null || value === null) + return failure("Dynamic entry is not representable"); + if (tag === DT_NULL) { + terminated = true; + break; + } + if ( + ![DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, 4, 0x6ffffef5].includes( + tag, + ) + ) + continue; + if (tags.has(tag) && tags.get(tag) !== value) + return failure("Conflicting dynamic table tags"); + tags.set(tag, value); + } + const sym = tags.get(DT_SYMTAB); + const str = tags.get(DT_STRTAB); + const stringSize = tags.get(DT_STRSZ); + if ( + !terminated || + sym === undefined || + str === undefined || + stringSize === undefined || + tags.get(DT_SYMENT) !== entrySize + ) + return failure( + "Stripped ELF lacks a complete dynamic symbol-table description", + ); + // DT_HASH's nchain is the dynsym count. GNU hash instead records the + // first hashed index and bounded buckets/chains. No payload-to-EOF walk. + let count: number; + const sysv = tags.get(4); + const gnu = tags.get(0x6ffffef5); + if (sysv !== undefined) { + const at = toOffset(sysv, 8); + if (at === null) return failure("DT_HASH header is not file-backed"); + const buckets = reader.u32(at)!; + count = reader.u32(at + 4)!; + if (toOffset(sysv, 8 + 4 * (buckets + count)) === null) + return failure("DT_HASH table is truncated"); + } else if (gnu !== undefined) { + const at = toOffset(gnu, 16); + if (at === null) return failure("DT_GNU_HASH header is not file-backed"); + const buckets = reader.u32(at)!; + const firstSymbol = reader.u32(at + 4)!; + const bloomWords = reader.u32(at + 8)!; + const prefixBytes = 16 + bloomWords * (is64 ? 8 : 4) + buckets * 4; + if ( + buckets === 0 || + bloomWords === 0 || + toOffset(gnu, prefixBytes) === null + ) + return failure("DT_GNU_HASH buckets are malformed"); + let last = 0; + const bucketsAt = at + 16 + bloomWords * (is64 ? 8 : 4); + for (let index = 0; index < buckets; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const value = reader.u32(bucketsAt + index * 4)!; + if (value !== 0 && value < firstSymbol) + return failure("DT_GNU_HASH bucket precedes its first symbol"); + last = Math.max(last, value); + } + count = firstSymbol; + if (last !== 0) { + let index = last; + while (true) { + if ((index - last) % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const span = prefixBytes + (index - firstSymbol + 1) * 4; + if (toOffset(gnu, span) === null) + return failure("DT_GNU_HASH chain is unterminated"); + const chain = reader.u32(at + span - 4)!; + index += 1; + if ((chain & 1) !== 0) { + count = index; + break; + } + } + } + } else + return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count"); + const offset = toOffset(sym, count * entrySize); + const stringOffset = toOffset(str, stringSize); + if (offset === null || stringOffset === null) + return failure("Dynamic symbol or string table is not file-backed"); + tables.push({ offset, count, stringOffset, stringSize }); + } + const wanted = new Set(symbolNames); + const symbols = new Map(); + const ambiguous = new Set(); + const failures: ElfParseFailure[] = []; + for (const table of tables) { + for (let index = 0; index < table.count; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = table.offset + index * entrySize; + const nameOffset = reader.u32(base)!; + const sectionIndex = reader.u16(base + (is64 ? 6 : 14))!; + const defined = + sectionIndex === 0xfff1 || + (sectionIndex > 0 && + sectionIndex < 0xff00 && + (dynamicFallback || sectionIndex < e_shnum)); + if (!defined || nameOffset >= table.stringSize) continue; + const name = reader.cString( + table.stringOffset + nameOffset, + table.stringOffset + table.stringSize, + ); + if (name === null || !wanted.has(name)) continue; + const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + const size = is64 ? reader.u64(base + 16) : reader.u32(base + 8); + if (value === null || size === null) { + failures.push({ reason: `Symbol ${name} has unrepresentable bounds` }); + continue; + } + const offset = toOffset(value, size); + if (offset === null) { + failures.push({ reason: `Symbol ${name} has no file-backed range` }); + continue; + } + const previous = symbols.get(name); + if ( + previous !== undefined && + (previous.offset !== offset || previous.size !== size) + ) + ambiguous.add(name); + else symbols.set(name, { name, offset, size }); + } + } + for (const name of ambiguous) { + symbols.delete(name); + failures.push({ reason: `Symbol ${name} has conflicting definitions` }); + } + for (const name of wanted) + if (!symbols.has(name)) + failures.push({ + reason: `symbol ${name} is not in the dynamic symbol table`, + }); + return { symbols: [...symbols.values()], failures }; +}; + +/** Read one Dart snapshot section header from its file offset. */ +export const readSnapshotHeader = ( + bytes: Buffer, + offset: number, +): { + magicValid: boolean; + kind: number | null; + headerLength: number | null; + hash: string | null; + complete: boolean; +} => { + const reader = new BoundedReader(bytes); + // The Dart magic bytes f5 f5 dc dc read little-endian. Kind and length + // fields are only meaningful in data-section headers; sections without + // the magic (instructions carry a different layout) report nulls. + const magic = reader.u32(offset); + if (magic !== 0xdcdcf5f5) + return { + magicValid: false, + kind: null, + headerLength: null, + hash: null, + complete: false, + }; + const headerLength = reader.u64(offset + 4); + const kind = reader.u64(offset + 12); + const candidate = bytes + .subarray( + offset + SNAPSHOT_HASH_OFFSET, + offset + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH, + ) + .toString("latin1"); + const hash = SNAPSHOT_HASH_PATTERN.test(candidate) ? candidate : null; + // Dart's stored length excludes the four magic bytes and includes the + // 20-byte fixed header and the version hash. Aligned data may follow it + // inside the exported symbol, but the serialized snapshot must fit. + // Kind 3 is FullAOT in the supported Dart 2.14 layout; enum values change + // across SDK versions, so other kinds remain uninterpreted observations. + const complete = + headerLength !== null && + headerLength >= SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH - 4 && + headerLength <= bytes.length - offset - 4 && + kind === 3 && + hash !== null; + return { magicValid: true, kind, headerLength, hash, complete }; +}; diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts new file mode 100644 index 0000000000..3c04d0473a --- /dev/null +++ b/src/flutter/ElfBuildId.ts @@ -0,0 +1,158 @@ +import type { FileHandle } from "node:fs/promises"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { + readElfHeader, + ELF_HEADER_MAX_BYTES, + type ElfParseFailure, +} from "./DartElf.js"; + +const MAX_METADATA_BYTES = 8 * 1024 * 1024; +const MAX_BUILD_IDS = 64; + +export interface ElfBuildIds { + readonly candidates: readonly string[]; + readonly failures: readonly ElfParseFailure[]; +} + +/** Read only ELF-declared PT_NOTE/SHT_NOTE ranges from one owned engine image. */ +export const readElfBuildIds = async ( + file: FileHandle, + totalBytes: number, + signal: AbortSignal | undefined, +): Promise => { + const candidates = new Set(); + const failure = (reason: string): ElfBuildIds => ({ + candidates: [...candidates], + failures: [{ reason }], + }); + let examined = 0; + const read = async (offset: number, size: number): Promise => { + signal?.throwIfAborted(); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + offset < 0 || + size < 0 || + size > totalBytes - offset || + size > MAX_METADATA_BYTES - examined + ) + return null; + examined += size; + const bytes = Buffer.alloc(size); + let copied = 0; + while (copied < size) { + const result = await file.read( + bytes, + copied, + size - copied, + offset + copied, + ); + signal?.throwIfAborted(); + if (result.bytesRead === 0) return null; + copied += result.bytesRead; + } + return bytes; + }; + const prefix = await read(0, Math.min(totalBytes, ELF_HEADER_MAX_BYTES)); + if (prefix === null) return failure("ELF header could not be read"); + const parsed = readElfHeader(prefix, totalBytes); + if (parsed.failure !== undefined) return failure(parsed.failure.reason); + const h = parsed.header; + const ranges = new Map(); + for (const table of [ + { + offset: h.e_phoff, + width: h.e_phentsize, + count: h.e_phnum, + program: true, + }, + { + offset: h.e_shoff, + width: h.e_shentsize, + count: h.e_shnum, + program: false, + }, + ]) { + const bytes = await read(table.offset, table.width * table.count); + if (bytes === null) + return failure( + "ELF note tables exceed the 8 MiB metadata budget or are truncated", + ); + for (let index = 0; index < table.count; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = index * table.width; + if ( + bytes.readUInt32LE(base + (table.program ? 0 : 4)) !== + (table.program ? 4 : 7) + ) + continue; + const offsetAt = + base + (table.program ? (h.is64 ? 8 : 4) : h.is64 ? 0x18 : 0x10); + const sizeAt = + base + (table.program ? (h.is64 ? 0x20 : 0x10) : h.is64 ? 0x20 : 0x14); + const offset = h.is64 + ? Number(bytes.readBigUInt64LE(offsetAt)) + : bytes.readUInt32LE(offsetAt); + const size = h.is64 + ? Number(bytes.readBigUInt64LE(sizeAt)) + : bytes.readUInt32LE(sizeAt); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + size > totalBytes - offset + ) + return failure("ELF note range is not file-backed"); + ranges.set(`${offset}:${size}`, { offset, size }); + } + } + const align4 = (value: number): number => Math.ceil(value / 4) * 4; + for (const range of ranges.values()) { + const notes = await read(range.offset, range.size); + if (notes === null) + return failure( + "ELF notes exceed the 8 MiB metadata budget or are truncated", + ); + let yieldedAt = 0; + for (let at = 0; at < notes.length;) { + if (at - yieldedAt >= 64 * 1024) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + yieldedAt = at; + } + if (notes.length - at < 12) { + if (notes.subarray(at).some((byte) => byte !== 0)) + return failure("ELF note header is truncated"); + break; + } + const namesz = notes.readUInt32LE(at); + const descsz = notes.readUInt32LE(at + 4); + const type = notes.readUInt32LE(at + 8); + const descriptor = at + 12 + align4(namesz); + const end = descriptor + descsz; + if (end > notes.length) + return failure("ELF note descriptor exceeds its declared note range"); + if ( + namesz === 4 && + type === 3 && + notes.subarray(at + 12, at + 16).equals(Buffer.from("GNU\0")) + ) { + if (descsz < 4 || descsz > 64) + return failure( + "GNU build-id descriptor exceeds the 4–64 byte projection", + ); + const candidate = notes.subarray(descriptor, end).toString("hex"); + if (!candidates.has(candidate) && candidates.size === MAX_BUILD_IDS) + return failure( + "ELF build-id candidates exceed the 64-value evidence budget", + ); + candidates.add(candidate); + } + at = align4(end); + } + } + signal?.throwIfAborted(); + return { candidates: [...candidates], failures: [] }; +}; diff --git a/src/flutter/FlutterBuild.fixture.ts b/src/flutter/FlutterBuild.fixture.ts new file mode 100644 index 0000000000..635ea04fb0 --- /dev/null +++ b/src/flutter/FlutterBuild.fixture.ts @@ -0,0 +1,267 @@ +import { Buffer } from "node:buffer"; + +/** The reference Gallery snapshot hash for synthetic fixtures. */ +export const REFERENCE_HASH = "65817c30a78bb44c3dc3771876b6010a"; + +/** A synthetic AOT image with four symbols, two data sections, and a pool. */ +export const aotFixtureImage = (hash: string = REFERENCE_HASH): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, + { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, + { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, + ]); + const pool = Buffer.concat([ + Buffer.from( + "package:adaptive_breakpoints/src/adaptive_breakpoints.dart\x00", + ), + Buffer.from("package:animations/src/fade_scale_transition.dart\x00"), + Buffer.from("dart:_http\x00dart:_http/crypto.dart\x00"), + Buffer.from("AdaptiveBreakpoint\x00FadeScaleTransition\x00"), + ]); + const vmSection = snapshotSection(hash); + const isolateSection = Buffer.concat([ + snapshotSection(hash), + Buffer.from([0]), + pool, + ]); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + vmSection, + Buffer.alloc(0x1800 - 0x1000 - vmSection.length), + Buffer.alloc(16), + Buffer.alloc(0x2000 - 0x1800 - 16), + isolateSection, + Buffer.alloc(0x600 - isolateSection.length), + Buffer.alloc(16), + ]); +}; + +export const buildElf64Image = ( + symbols: ReadonlyArray<{ name: string; value: number; size: number }>, +): Buffer => { + const ELF_HEADER = 64; + const PHENT = 56; + const PHNUM = 1; + const SHENT = 64; + const phoff = ELF_HEADER; + const shoffAligned = phoff + PHENT * PHNUM; + // Place payload data after the three section headers so nothing overlaps. + const dynsymOff = shoffAligned + 3 * SHENT + 16; + const dynstrOff = dynsymOff + symbols.length * 24; + const image = Buffer.alloc(dynstrOff + 256); + // ELF header + image.writeUInt32LE(0x464c457f, 0); + image[4] = 2; // ELFCLASS64 + image[5] = 1; // little-endian + image[6] = 1; // ELF identification version + image.writeUInt16LE(3, 0x10); + image.writeUInt16LE(0x003e, 0x12); // e_machine x86_64 + image.writeUInt32LE(1, 0x14); // e_version + image.writeUInt16LE(ELF_HEADER, 0x34); + image.writeBigUInt64LE(BigInt(phoff), 0x20); + image.writeBigUInt64LE(BigInt(shoffAligned), 0x28); + image.writeUInt16LE(PHENT, 0x36); + image.writeUInt16LE(PHNUM, 0x38); + image.writeUInt16LE(SHENT, 0x3a); + image.writeUInt16LE(2, 0x3c); // e_shnum: null + dynsym + // Program header: one PT_LOAD mapping identity + const ph = phoff; + image.writeUInt32LE(1, ph); // PT_LOAD + image.writeBigUInt64LE(BigInt(0), ph + 8); // offset + image.writeBigUInt64LE(BigInt(0), ph + 0x10); // vaddr + image.writeBigUInt64LE( + BigInt( + Math.max( + image.length, + ...symbols.map((symbol) => symbol.value + symbol.size), + ), + ), + ph + 0x20, + ); // p_filesz covers the materialized payload + // Section headers + const sh = shoffAligned; + // null section stays zero + const dyn = sh + SHENT; + image.writeUInt32LE(11, dyn + 4); // SHT_DYNSYM + image.writeBigUInt64LE(BigInt(dynsymOff), dyn + 0x18); + image.writeBigUInt64LE(BigInt(symbols.length * 24), dyn + 0x20); + image.writeBigUInt64LE(BigInt(24), dyn + 0x38); + image.writeUInt32LE(2, dyn + 0x28); // sh_link -> strtab section index + // dynstr section header + const str = dyn + SHENT; + image.writeUInt32LE(3, str + 4); // SHT_STRTAB + image.writeBigUInt64LE(BigInt(dynstrOff), str + 0x18); + image.writeBigUInt64LE(BigInt(256), str + 0x20); + image.writeUInt16LE(3, 0x3c); // e_shnum includes strtab + // dynstr: leading NUL then names + let strCursor = dynstrOff + 1; + const nameOffsets = symbols.map((symbol) => { + image.write(symbol.name, strCursor, "latin1"); + const at = strCursor - dynstrOff; + strCursor += symbol.name.length + 1; + return at; + }); + // dynsym entries + symbols.forEach((symbol, index) => { + const base = dynsymOff + index * 24; + image.writeUInt32LE(nameOffsets[index]!, base); + image.writeBigUInt64LE(BigInt(symbol.value), base + 8); + image.writeBigUInt64LE(BigInt(symbol.size), base + 16); + image.writeUInt16LE(1, base + 6); // Defined export (not SHN_UNDEF). + image[base + 4] = 0x10; // GLOBAL FUNC + }); + return image; +}; + +/** + * Build a section-stripped ELF64 image (e_shnum 0, ET_DYN) whose exported + * symbols are reachable only through the PT_DYNAMIC tag-value pairs, the + * way release toolchains ship `libapp.so`. + */ +export const buildStrippedElf64Image = ( + symbols: ReadonlyArray<{ name: string; value: number; size: number }>, +): Buffer => { + const ELF_HEADER = 64; + const PHENT = 56; + const PHNUM = 2; + const dynamicEntry = 16; + // DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, DT_HASH, DT_NULL. + const DYNAMIC_ENTRIES = 6; + const phoff = ELF_HEADER; + const dynamicOff = phoff + PHENT * PHNUM; + const dynsymOff = dynamicOff + DYNAMIC_ENTRIES * dynamicEntry; + const dynstrOff = dynsymOff + symbols.length * 24; + const strtabBytes = 256; + const hashOff = dynstrOff + strtabBytes; + const image = Buffer.alloc(hashOff + 8 + 4 * (1 + symbols.length)); + image.writeUInt32LE(1, hashOff); + image.writeUInt32LE(symbols.length, hashOff + 4); + // ELF header + image.writeUInt32LE(0x464c457f, 0); + image[4] = 2; // ELFCLASS64 + image[5] = 1; // little-endian + image[6] = 1; + image.writeUInt16LE(3, 0x10); + image.writeUInt16LE(0x003e, 0x12); // e_machine x86_64 + image.writeUInt32LE(1, 0x14); + image.writeUInt16LE(ELF_HEADER, 0x34); + image.writeBigUInt64LE(BigInt(phoff), 0x20); + image.writeUInt16LE(PHENT, 0x36); + image.writeUInt16LE(PHNUM, 0x38); + // e_shoff, e_shentsize, and e_shnum stay zero: no section headers exist. + // PT_LOAD identity-maps the file so symbol vaddrs resolve to offsets. + const load = phoff; + image.writeUInt32LE(1, load); + image.writeBigUInt64LE(BigInt(0), load + 8); + image.writeBigUInt64LE(BigInt(0), load + 0x10); + image.writeBigUInt64LE( + BigInt( + Math.max( + image.length, + ...symbols.map((symbol) => symbol.value + symbol.size), + ), + ), + load + 0x20, + ); // p_filesz covers fixture symbols + // PT_DYNAMIC locates the symbol table for stripped images. + const dyn = phoff + PHENT; + image.writeUInt32LE(2, dyn); + image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 8); // p_offset + image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 0x10); // p_vaddr + image.writeBigUInt64LE(BigInt(DYNAMIC_ENTRIES * dynamicEntry), dyn + 0x20); + const pairs: ReadonlyArray = [ + [6, dynsymOff], + [5, dynstrOff], + [10, strtabBytes], + [11, 24], + [4, hashOff], + ]; + pairs.forEach(([tag, value], index) => { + const base = dynamicOff + index * dynamicEntry; + image.writeBigUInt64LE(BigInt(tag), base); + image.writeBigUInt64LE(BigInt(value), base + 8); + }); + // The final entry stays zero: DT_NULL terminates the walk. + // dynstr: leading NUL then names + let strCursor = dynstrOff + 1; + const nameOffsets = symbols.map((symbol) => { + image.write(symbol.name, strCursor, "latin1"); + const at = strCursor - dynstrOff; + strCursor += symbol.name.length + 1; + return at; + }); + symbols.forEach((symbol, index) => { + const base = dynsymOff + index * 24; + image.writeUInt32LE(nameOffsets[index]!, base); + image.writeBigUInt64LE(BigInt(symbol.value), base + 8); + image.writeBigUInt64LE(BigInt(symbol.size), base + 16); + image.writeUInt16LE(1, base + 6); // Defined even when section headers are stripped. + }); + return image; +}; + +/** A minimal Dart snapshot data section carrying a 32-hex hash. */ +export const snapshotSection = (hash: string): Buffer => + Buffer.concat([ + Buffer.from([ + 0xf5, 0xf5, 0xdc, 0xdc, 48, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, + ]), + Buffer.from(hash, "latin1"), + ]); + +/** A real ELF container declaring GNU note ownership, with an unrelated payload prefix. */ +export const engineFixtureImage = (options: { + readonly notes: Buffer; + readonly prefix?: Buffer; + readonly labels?: readonly string[]; + readonly bits?: 32 | 64; + readonly ownership?: "program" | "section" | "both"; +}): Buffer => { + const is64 = options.bits !== 32; + const ownership = options.ownership ?? "program"; + const program = ownership !== "section"; + const section = ownership !== "program"; + const header = is64 ? 64 : 52; + const phent = is64 ? 56 : 32; + const shent = is64 ? 64 : 40; + const noteAt = 0x400; + const labels = Buffer.from(`\0${(options.labels ?? []).join("\0")}\0`); + const image = Buffer.alloc(noteAt + options.notes.length + labels.length); + image.writeUInt32LE(0x464c457f); + image[4] = is64 ? 2 : 1; + image[5] = 1; + image[6] = 1; + image.writeUInt16LE(3, 0x10); + image.writeUInt32LE(1, 0x14); + image.writeUInt16LE(header, is64 ? 0x34 : 0x28); + const word = (value: number, offset: number): void => { + if (is64) image.writeBigUInt64LE(BigInt(value), offset); + else image.writeUInt32LE(value, offset); + }; + word(header, is64 ? 0x20 : 0x1c); + image.writeUInt16LE(phent, is64 ? 0x36 : 0x2a); + image.writeUInt16LE(program ? 2 : 1, is64 ? 0x38 : 0x2c); + image.writeUInt32LE(1, header); // PT_LOAD + word(image.length, header + (is64 ? 0x20 : 0x10)); + if (program) { + const ph = header + phent; + image.writeUInt32LE(4, ph); // PT_NOTE + word(noteAt, ph + (is64 ? 8 : 4)); + word(options.notes.length, ph + (is64 ? 0x20 : 0x10)); + } + if (section) { + word(0x100, is64 ? 0x28 : 0x20); + image.writeUInt16LE(shent, is64 ? 0x3a : 0x2e); + image.writeUInt16LE(2, is64 ? 0x3c : 0x30); + const sh = 0x100 + shent; + image.writeUInt32LE(7, sh + 4); // SHT_NOTE + word(noteAt, sh + (is64 ? 0x18 : 0x10)); + word(options.notes.length, sh + (is64 ? 0x20 : 0x14)); + } + options.prefix?.copy(image, 0x300, 0, 0x100); + options.notes.copy(image, noteAt); + labels.copy(image, noteAt + options.notes.length); + return image; +}; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts new file mode 100644 index 0000000000..2c088b2c9c --- /dev/null +++ b/src/flutter/FlutterBuildProvider.ts @@ -0,0 +1,1230 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, open as openFile, rm, stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; + +import type { + ExecutionOptions, + ProviderAvailability, +} from "../application/AnalysisProvider.js"; +import { + createAnalysisExecution, + type AnalysisExecution, +} from "../application/AnalysisProvider.js"; +import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, +} from "../artifacts/ArtifactReader.js"; +import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { + openRegularFile, + RegularFileAdmissionFailure, + sameRegularFileState, +} from "../filesystem/RegularFile.js"; +import { + MAX_SNAPSHOT_HASH_CANDIDATES, + LabeledStringScanner, + SnapshotHashScanner, +} from "./FlutterPayloadScan.js"; +import { + ELF_HEADER_MAX_BYTES, + readElfHeader, + readElfSymbols, + readSnapshotHeader, +} from "./DartElf.js"; +import { readElfBuildIds } from "./ElfBuildId.js"; + +/** Stable identity for the Flutter build-identification provider. */ +export const FLUTTER_PROVIDER_IDENTITY = { + id: "flutter", + name: "Flutter build identification", + version: null, +} as const; + +const APK_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024; +const LIBRARY_MAX_BYTES = 256 * 1024 * 1024; +/** Cumulative cap across every library entry in one identification. */ +const TOTAL_EXTRACTION_MAX_BYTES = 768 * 1024 * 1024; +/** The APK itself must fit within this budget before any copy. */ +const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; +// At most 32 ABI projections (two libraries each). With bounded hashes and +// engine labels this keeps expanded result metadata below 512 KiB for ordinary +// ABI names, independently of the archive's compressed-byte budget. +const MAX_FLUTTER_ABIS = 32; +const MAX_LIBRARY_PATH_BYTES = 1024; +const MAX_POOL_URIS = 500; +const MAX_POOL_TOKENS = 2_000; +/** A complete AOT image carries exactly one VM and one isolate section. */ +const SYMBOL_PAIR_COUNT = 2; +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; + +/** + * Stream a local file digest without retaining large files in memory. + * Aborts between chunks so a cancellation cannot run a digest to completion. + */ +const sha256File = async ( + operation: string, + path: string, + signal: AbortSignal | undefined, +): Promise => { + const hash = createHash("sha256"); + const stream = createReadStream(path, signal === undefined ? {} : { signal }); + for await (const chunk of stream) { + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); + hash.update(chunk as Buffer); + } + return hash.digest("hex"); +}; + +/** One owned snapshot root: a read-only copy of the admitted input. */ +interface OwnedSnapshot { + readonly root: string; + readonly path: string; + readonly bytes: number; + readonly sha256: string; +} + +interface LibraryFacts { + readonly libapp: { + present: boolean; + bytes: number | null; + sha256: string | null; + elfHeaderValid: boolean | null; + sources: number; + candidates: readonly string[]; + }; + readonly libflutter: { + present: boolean; + bytes: number | null; + sha256: string | null; + buildId: string | null; + buildIdCandidates: readonly string[]; + buildIdFailures: readonly string[]; + dartVersion: string | null; + dartVersionCapped: boolean; + toolchainLines: readonly string[]; + toolchainCapped: boolean; + }; +} + +const emptyFacts = (): LibraryFacts => ({ + libapp: { + present: false, + bytes: null, + sha256: null, + elfHeaderValid: null, + sources: 0, + candidates: [], + }, + libflutter: { + present: false, + bytes: null, + sha256: null, + buildId: null, + buildIdCandidates: [], + buildIdFailures: [], + dartVersion: null, + dartVersionCapped: false, + toolchainLines: [], + toolchainCapped: false, + }, +}); + +/** Translate a ZIP reader rejection into the typed analysis error algebra. */ +const isCancellation = (cause: unknown): boolean => + cause instanceof AnalysisCancelledError || + (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || + (cause instanceof Error && cause.name === "AbortError") || + (typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ABORT_ERR"); + +const mapInputFailure = ( + operation: string, + path: string, + cause: unknown, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (isCancellation(cause)) + return new AnalysisCancelledError(operation, { cause }); + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + "memory", + cause.message, + { max_metadata_read_bytes: APK_METADATA_READ_MAX_BYTES }, + { cause }, + ); + const message = cause instanceof Error ? cause.message : String(cause); + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `The APK could not be read as a package: ${path}: ${message}`, + }, + ]); +}; + +/** Provider-owned storage failures must never be attributed to caller input. */ +const mapOwnedIoFailure = ( + operation: string, + cause: unknown, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (isCancellation(cause)) + return new AnalysisCancelledError(operation, { cause }); + if ( + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ) + return new AnalysisResourceConstraintError( + operation, + "file-size", + `Provider-owned Flutter snapshot storage failed (${cause.code}): ${cause.message}`, + null, + { cause }, + ); + return new AnalysisProtocolError(operation, { cause }); +}; + +/** ZIP format failures describe caller bytes; its descriptor is provider-owned. */ +const mapPackageFailure = ( + operation: string, + path: string, + cause: unknown, +): AnalysisError => + cause instanceof Error && "code" in cause && typeof cause.code === "string" + ? mapOwnedIoFailure(operation, cause) + : mapInputFailure(operation, path, cause); + +interface FlutterLibrary { + readonly entry: ArtifactEntry; + readonly abi: string; + readonly kind: "libapp" | "libflutter"; +} + +/** One inventory owns occurrence ambiguity and metadata expansion for both tools. */ +const collectFlutterLibraries = async ( + reader: ZipArtifactReader, + operation: string, + path: string, + signal: AbortSignal | undefined, +): Promise => { + const libraries: FlutterLibrary[] = []; + const paths = new Set(); + const abis = new Set(); + try { + for await (const entry of reader.entries(signal)) { + if (entry.kind !== "file") continue; + const match = /^lib\/([^/]+)\/(libapp|libflutter)\.so$/u.exec(entry.path); + if (match === null) continue; + if (paths.has(entry.path)) + throw new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: `The selected APK has duplicate Flutter library members: ${path}: ${entry.path}`, + }, + ]); + paths.add(entry.path); + abis.add(match[1]!); + if ( + abis.size > MAX_FLUTTER_ABIS || + Buffer.byteLength(entry.path) > MAX_LIBRARY_PATH_BYTES + ) + throw new AnalysisResourceConstraintError( + operation, + "memory", + "Flutter library inventory exceeds the bounded ABI projection budget", + { + max_flutter_abis: MAX_FLUTTER_ABIS, + max_library_path_bytes: MAX_LIBRARY_PATH_BYTES, + }, + ); + libraries.push({ + entry, + abi: match[1]!, + kind: match[2] === "libapp" ? "libapp" : "libflutter", + }); + } + return libraries; + } catch (cause) { + throw mapPackageFailure(operation, path, cause); + } +}; + +/** The generator owns iterator closure, including early extraction failures. */ +async function* openEntrySafely( + reader: ZipArtifactReader, + entry: ArtifactEntry, + operation: string, + path: string, + signal: AbortSignal | undefined, +): AsyncGenerator { + try { + yield* await reader.open(entry, signal); + } catch (cause) { + throw mapPackageFailure(operation, path, cause); + } +} + +const checkApkSize = (operation: string, bytes: number): void => { + if (bytes > APK_MAX_BYTES) + throw new AnalysisResourceConstraintError( + operation, + "file-size", + `The selected APK exceeds the ${String(APK_MAX_BYTES)}-byte snapshot budget`, + { max_apk_bytes: APK_MAX_BYTES, observed_bytes: bytes }, + ); +}; + +export interface FlutterProviderOptions { + /** Deterministic root removal for tests; production removes recursively. */ + readonly removeRoot?: (path: string) => Promise; +} + +/** + * Flutter build identification by pure parsing: reads `lib//libapp.so` + * and `libflutter.so` through the hardened ZIP reader and reports the Dart + * snapshot hash, GNU build-id, and any labeled version strings verbatim. + * Runs no engine, installs nothing, and performs no external lookups. + */ +export class FlutterBuildProvider { + private readonly ownedRoots = new Map< + string, + Set<{ close(): Promise }> + >(); + private readonly removeRoot: (path: string) => Promise; + private readonly cleanupFailures = new Map(); + + constructor(options: FlutterProviderOptions = {}) { + this.removeRoot = + options.removeRoot ?? + ((path) => rm(path, { recursive: true, force: true })); + } + + /** + * Snapshot the admitted input into a provider-owned, read-only copy so a + * concurrently modified original cannot mix into one observation. The + * digest is taken from the snapshot, and every later read uses the + * snapshot path. + */ + private async ownSnapshot( + operation: string, + path: string, + signal: AbortSignal | undefined, + ): Promise { + let root: string; + try { + root = await mkdtemp(join(tmpdir(), "rea-flutter-")); + } catch (cause) { + throw mapOwnedIoFailure(operation, cause); + } + this.ownedRoots.set(root, new Set()); + const snapshotPath = join(root, "input.apk"); + const readSource = async (read: () => Promise): Promise => { + try { + return await read(); + } catch (cause) { + throw mapInputFailure(operation, path, cause); + } + }; + try { + // Pin the admitted source: capture its identity before the copy and + // revalidate after, so an in-place replacement or swap mid-copy is + // refused instead of publishing mixed bytes under the original path. + // The copy itself is streamed and abort-checked so cancellation + // stops the write promptly instead of draining the whole file. + let source: OwnedFileHandle; + try { + source = this.ownResource( + root, + new OwnedFileHandle( + await openRegularFile(path, { symlinks: "follow", signal }), + ), + ); + } catch (cause) { + if (cause instanceof RegularFileAdmissionFailure) { + this.ownResource(root, cause.owner); + throw mapInputFailure(operation, path, cause.cause); + } + throw mapInputFailure(operation, path, cause); + } + const before = await readSource(() => + source.handle.stat({ bigint: true }), + ); + checkApkSize(operation, Number(before.size)); + const output = this.ownResource( + root, + new OwnedFileHandle(await openFile(snapshotPath, "wx")), + ); + const chunk = Buffer.alloc(64 * 1024); + let copiedBytes = 0; + while (copiedBytes <= Number(before.size)) { + signal?.throwIfAborted(); + const { bytesRead } = await readSource(() => + source.handle.read( + chunk, + 0, + Math.min(chunk.length, Number(before.size) - copiedBytes + 1), + copiedBytes, + ), + ); + signal?.throwIfAborted(); + if (bytesRead === 0) break; + copiedBytes += bytesRead; + if (BigInt(copiedBytes) > before.size) + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected file grew during snapshotting", + ); + await output.handle.writeFile(chunk.subarray(0, bytesRead)); + } + await this.releaseResource(root, output); + const [after, currentPath] = await Promise.all([ + readSource(() => source.handle.stat({ bigint: true })), + stat(path, { bigint: true }).catch((cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + throw mapInputFailure(operation, path, cause); + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected path became unavailable during snapshotting", + { cause }, + ); + }), + ]); + if ( + BigInt(copiedBytes) !== before.size || + !sameRegularFileState(before, after) || + !sameRegularFileState(before, currentPath) + ) + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected file identity or contents changed during snapshotting", + ); + await this.releaseResource(root, source); + await chmod(snapshotPath, 0o400); + const info = await stat(snapshotPath); + const sha256 = await sha256File(operation, snapshotPath, signal); + return { root, path: snapshotPath, bytes: info.size, sha256 }; + } catch (cause) { + await this.discardRoot(root); + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause }); + throw mapOwnedIoFailure(operation, cause); + } + } + + private readerFor(snapshot: OwnedSnapshot): ZipArtifactReader { + return this.ownResource( + snapshot.root, + new ZipArtifactReader(snapshot.path, "apk", APK_METADATA_READ_MAX_BYTES), + ); + } + + private ownResource }>( + root: string, + resource: T, + ): T { + this.ownedRoots.get(root)!.add(resource); + return resource; + } + + private async releaseResource( + root: string, + resource: { close(): Promise }, + ): Promise { + await resource.close(); + this.ownedRoots.get(root)?.delete(resource); + } + + /** Close every owned handle before removing its root; retain any failure. */ + private async discardRoot(root: string): Promise { + const resources = this.ownedRoots.get(root); + if (resources === undefined) return true; + const attempts = await Promise.allSettled( + [...resources].map((resource) => this.releaseResource(root, resource)), + ); + const failures = attempts.flatMap((attempt) => + attempt.status === "rejected" ? [attempt.reason as unknown] : [], + ); + if (failures.length > 0) { + this.cleanupFailures.set(root, failures); + return false; + } + try { + await this.removeRoot(root); + this.ownedRoots.delete(root); + this.cleanupFailures.delete(root); + return true; + } catch (cause) { + this.cleanupFailures.set(root, [cause]); + return false; + } + } + + /** Pure parsing; the provider is always available. */ + async inspectAvailability(): Promise { + return { + status: "available", + code: null, + reason: null, + diagnostics: { engine: "static-apk-parsing" }, + }; + } + + /** Retain failed snapshot cleanup ownership and retry it on close. */ + async close(): Promise { + const failures: unknown[] = []; + for (const root of [...this.ownedRoots.keys()]) { + if (!(await this.discardRoot(root))) + failures.push( + new AggregateError( + this.cleanupFailures.get(root)!, + `snapshot root retained: ${root}`, + ), + ); + } + if (failures.length > 0) + throw new AggregateError(failures, "Flutter snapshot cleanup failed"); + } + + async execute( + request: FlutterRequest, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted === true) + return err(new AnalysisCancelledError(request.operation)); + const { path } = request.input; + const signal = options?.signal; + let snapshot: OwnedSnapshot | undefined; + let result: Result; + try { + snapshot = await this.ownSnapshot(request.operation, path, signal); + const reader = this.readerFor(snapshot); + const libraries = await collectFlutterLibraries( + reader, + request.operation, + path, + signal, + ); + result = + request.operation === "inspect_dart_aot" + ? await this.inspectDartAot( + path, + request.input.abi, + signal, + snapshot, + reader, + libraries, + ) + : await this.identifyFlutterBuild( + path, + signal, + snapshot, + reader, + libraries, + ); + } catch (cause) { + result = err(mapOwnedIoFailure(request.operation, cause)); + } finally { + if (snapshot !== undefined) await this.discardRoot(snapshot.root); + } + if (signal?.aborted === true) + return err( + new AnalysisCancelledError(request.operation, { cause: signal.reason }), + ); + return result; + } + + private async identifyFlutterBuild( + path: string, + signal: AbortSignal | undefined, + snapshot: OwnedSnapshot, + reader: ZipArtifactReader, + flutterEntries: readonly FlutterLibrary[], + ): Promise> { + const operation = "identify_flutter_build"; + for (const { entry } of flutterEntries) { + if (entry.declaredSize !== null && entry.declaredSize > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `${entry.path} exceeds the library read budget`, + { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path }, + ), + ); + } + // Preflight: refuse before any I/O when the declared entry sizes + // already exceed the cumulative budget, even though streaming might + // discover smaller actual sizes. + const declaredTotal = flutterEntries.reduce( + (total, { entry }) => total + (entry.declaredSize ?? 0), + 0, + ); + if (declaredTotal > TOTAL_EXTRACTION_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `declared library sizes total ${String(declaredTotal)} bytes, exceeding the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte cumulative budget before any extraction`, + { + max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES, + declared_total: declaredTotal, + }, + ), + ); + + const facts = new Map(); + let extractedTotal = 0; + let libraryIndex = 0; + for (const { entry, abi, kind } of flutterEntries) { + const stream = openEntrySafely(reader, entry, operation, path, signal); + const hash = createHash("sha256"); + const snapshotScanner = new SnapshotHashScanner(); + const labeledScanner = new LabeledStringScanner(); + const engineFile = + kind === "libflutter" + ? this.ownResource( + snapshot.root, + new OwnedFileHandle( + await openFile( + join(snapshot.root, `engine-${libraryIndex++}.so`), + "wx+", + ), + ), + ) + : null; + let bytes = 0; + const headerPrefix = Buffer.alloc(ELF_HEADER_MAX_BYTES); + let headerBytes = 0; + for await (const chunk of stream) { + if (signal?.aborted === true) + return err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + }), + ); + const view = chunk as Buffer; + headerBytes += view.copy( + headerPrefix, + headerBytes, + 0, + Math.min(view.length, headerPrefix.length - headerBytes), + ); + bytes += view.byteLength; + if (bytes > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `${entry.path} exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, + { + max_library_bytes: LIBRARY_MAX_BYTES, + entry_path: entry.path, + }, + ), + ); + hash.update(view); + if (kind === "libapp") snapshotScanner.push(view); + else { + labeledScanner.push(view); + await engineFile!.handle.writeFile(view); + } + // Enforce the cumulative cap inside the chunk loop so refusal + // happens before the remaining bytes are streamed and hashed. + if (extractedTotal + bytes > TOTAL_EXTRACTION_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `cumulative library extraction across ${String(flutterEntries.length)} entries exceeds the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte budget`, + { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES }, + ), + ); + } + extractedTotal += bytes; + const digest = hash.digest("hex"); + const current = facts.get(abi) ?? emptyFacts(); + if (kind === "libapp") { + const scan = snapshotScanner.result(); + if (scan.capped) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Snapshot hash candidates exceed the retained evidence budget", + { + max_snapshot_hash_candidates: MAX_SNAPSHOT_HASH_CANDIDATES, + entry_path: entry.path, + }, + ), + ); + facts.set(abi, { + ...current, + libapp: { + present: true, + bytes, + sha256: digest, + elfHeaderValid: + readElfHeader(headerPrefix.subarray(0, headerBytes), bytes) + .header !== undefined, + sources: scan.sources, + candidates: scan.candidates, + }, + }); + } else { + const labeled = labeledScanner.result(); + const buildIds = await readElfBuildIds( + engineFile!.handle, + bytes, + signal, + ); + await this.releaseResource(snapshot.root, engineFile!); + facts.set(abi, { + ...current, + libflutter: { + present: true, + bytes, + sha256: digest, + buildId: + buildIds.failures.length === 0 && buildIds.candidates.length === 1 + ? buildIds.candidates[0]! + : null, + buildIdCandidates: buildIds.candidates, + buildIdFailures: buildIds.failures.map((failure) => failure.reason), + ...labeled, + }, + }); + } + } + const abis = [...facts.entries()] + .map(([abi, value]) => ({ + abi, + libapp: { + present: value.libapp.present, + bytes: value.libapp.bytes, + sha256: value.libapp.sha256, + elf_header_valid: value.libapp.elfHeaderValid, + snapshot_hash_sources: value.libapp.sources, + snapshot_hash_candidates: [...value.libapp.candidates], + snapshot_hash: + value.libapp.candidates.length === 1 + ? value.libapp.candidates[0]! + : null, + }, + libflutter: { + present: value.libflutter.present, + bytes: value.libflutter.bytes, + sha256: value.libflutter.sha256, + build_id: value.libflutter.buildId, + build_id_candidates: [...value.libflutter.buildIdCandidates], + dart_version: value.libflutter.dartVersion, + dart_version_capped: value.libflutter.dartVersionCapped, + toolchain_lines: [...value.libflutter.toolchainLines], + toolchain_capped: value.libflutter.toolchainCapped, + }, + })) + .sort((left, right) => left.abi.localeCompare(right.abi)); + const flutterDetected = abis.some((entry) => entry.libapp.present); + const partial = + abis.some( + (entry) => + (entry.libapp.present && + (entry.libapp.snapshot_hash === null || + entry.libapp.snapshot_hash_sources !== 2 || + entry.libapp.elf_header_valid !== true)) || + entry.libflutter.toolchain_capped || + entry.libflutter.dart_version_capped || + entry.libapp.present !== entry.libflutter.present, + ) || + [...facts.values()].some( + (value) => + value.libflutter.buildIdFailures.length > 0 || + value.libflutter.buildIdCandidates.length > 1, + ); + return ok( + createAnalysisExecution( + { + target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, + flutter_detected: flutterDetected, + abis, + coverage: partial ? "partial" : "complete", + }, + FLUTTER_PROVIDER_IDENTITY, + { + subject: { path, format: "apk", sha256: snapshot.sha256 }, + locations: [{ kind: "artifact-path", path }], + limitations: [ + "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", + "The snapshot hash identifies a Dart SDK build only against an external mapping dataset; REA performs no lookup", + "Release engine builds often carry no labeled Dart version string; absence is reported, not guessed", + "Engine build IDs come only from ELF-declared PT_NOTE/SHT_NOTE records; conflicting or incompletely examined notes preserve candidates and report partial coverage", + ...[...facts].flatMap(([abi, value]) => [ + ...value.libflutter.buildIdFailures.map( + (reason) => `lib/${abi}/libflutter.so: ${reason}`, + ), + ...(value.libflutter.buildIdCandidates.length > 1 + ? [`lib/${abi}/libflutter.so has conflicting GNU build IDs`] + : []), + ]), + ...abis + .filter((entry) => entry.libflutter.toolchain_capped) + .map( + (entry) => + `lib/${entry.abi}/libflutter.so has toolchain labels omitted by the complete-label grammar or the 4-line projection`, + ), + ...abis + .filter((entry) => entry.libflutter.dart_version_capped) + .map( + (entry) => + `lib/${entry.abi}/libflutter.so has Dart version labels omitted by the complete-label grammar or the single-version projection`, + ), + "The snapshot hash sits 20 bytes after each snapshot magic in the AOT payload; findings are observations of that layout", + "ELF header validity checks the supported header layout and declared table extents; identification does not resolve symbols or validate table contents", + ...abis + .filter( + (entry) => + entry.libapp.present && + entry.libapp.snapshot_hash_sources !== 2, + ) + .map( + (entry) => + `lib/${entry.abi}/libapp.so carries ${String(entry.libapp.snapshot_hash_sources)} snapshot section(s); a complete AOT payload carries two, so the library is truncated or nonstandard`, + ), + ...abis + .filter( + (entry) => + entry.libapp.present && + entry.libapp.elf_header_valid !== true, + ) + .map( + (entry) => + `lib/${entry.abi}/libapp.so has a truncated, malformed, or unsupported ELF header or table extent; byte-scanned hashes remain observations only`, + ), + ...(flutterDetected + ? [] + : [ + "No lib/*/libapp.so was found; the target does not carry a Flutter AOT payload", + ]), + ], + }, + ), + ); + } + + private async inspectDartAot( + path: string, + abi: string | undefined, + signal: AbortSignal | undefined, + snapshot: OwnedSnapshot, + reader: ZipArtifactReader, + libraries: readonly FlutterLibrary[], + ): Promise> { + const candidates = libraries.filter((library) => library.kind === "libapp"); + if (candidates.length === 0) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + "No lib//libapp.so was found; the target carries no Dart AOT payload", + ), + ); + const preference = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"]; + const selected = + abi === undefined + ? [...candidates].sort( + (left, right) => + (preference.indexOf(left.abi) + 1 || preference.length + 1) - + (preference.indexOf(right.abi) + 1 || preference.length + 1) || + left.abi.localeCompare(right.abi), + )[0]! + : candidates.find((candidate) => candidate.abi === abi); + if (selected === undefined) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + `No libapp.so was found for ABI ${abi ?? "(none)"}; available: ${candidates + .map((candidate) => candidate.abi) + .join(", ")}`, + ), + ); + const declared = selected.entry.declaredSize ?? LIBRARY_MAX_BYTES + 1; + if (declared > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + "inspect_dart_aot", + "file-size", + `lib/${selected.abi}/libapp.so declares ${String(declared)} bytes, exceeding the ${String(LIBRARY_MAX_BYTES)}-byte read budget`, + { max_library_bytes: LIBRARY_MAX_BYTES }, + ), + ); + const imagePath = join(snapshot.root, "libapp.so"); + const imageHash = createHash("sha256"); + const imageWriter = this.ownResource( + snapshot.root, + new OwnedFileHandle(await openFile(imagePath, "wx")), + ); + { + const stream = openEntrySafely( + reader, + selected.entry, + "inspect_dart_aot", + path, + signal, + ); + let total = 0; + for await (const chunk of stream) { + if (signal?.aborted === true) + return err(new AnalysisCancelledError("inspect_dart_aot")); + const view = chunk as Buffer; + total += view.byteLength; + if (total > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + "inspect_dart_aot", + "file-size", + `lib/${selected.abi}/libapp.so exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, + { max_library_bytes: LIBRARY_MAX_BYTES }, + ), + ); + imageHash.update(view); + await imageWriter.handle.writeFile(view); + } + } + await this.releaseResource(snapshot.root, imageWriter); + const imageSha256 = imageHash.digest("hex"); + // Allocate exactly the budget-checked size before reading, so the + // buffer cannot exceed the declared library size plus one byte. + const image = Buffer.alloc(declared + 1); + let imageLength = 0; + { + const file = this.ownResource( + snapshot.root, + new OwnedFileHandle(await openFile(imagePath, "r")), + ); + while (imageLength < image.length) { + if (signal?.aborted === true) + return err(new AnalysisCancelledError("inspect_dart_aot")); + const result = await file.handle.read( + image, + imageLength, + image.length - imageLength, + null, + ); + signal?.throwIfAborted(); + if (result.bytesRead === 0) break; + imageLength += result.bytesRead; + } + await this.releaseResource(snapshot.root, file); + } + signal?.throwIfAborted(); + const bounded = image.subarray(0, imageLength); + const symbolNames = [ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ]; + const sections: { + name: string; + offset: number; + size: number; + magic_valid: boolean; + kind: number | null; + header_length: number | null; + }[] = []; + const dataHeaders: { + name: string; + offset: number; + size: number; + header: ReturnType; + }[] = []; + let isolateData: Buffer | null = null; + const inventory = await readElfSymbols(bounded, symbolNames, signal); + for (const symbol of inventory.symbols) { + const name = symbol.name; + const header = name.endsWith("Data") + ? readSnapshotHeader( + bounded.subarray(symbol.offset, symbol.offset + symbol.size), + 0, + ) + : null; + sections.push({ + name, + offset: symbol.offset, + size: symbol.size, + magic_valid: header?.magicValid ?? false, + kind: header?.kind ?? null, + header_length: header?.headerLength ?? null, + }); + if (header !== null) dataHeaders.push({ ...symbol, header }); + if (name === "_kDartIsolateSnapshotData") + isolateData = image.subarray( + symbol.offset, + symbol.offset + symbol.size, + ); + } + // Each exported data symbol owns one header at its start. Matching + // records elsewhere in a symbol are not evidence for the other + // symbol's hash. Count a physical header only once for aliased exports. + const observedOffsets = new Set(); + let aliasedDataSymbols = false; + const hashes: string[] = []; + for (const section of dataHeaders) { + if (observedOffsets.has(section.offset)) { + aliasedDataSymbols = true; + continue; + } + observedOffsets.add(section.offset); + if (section.header.hash !== null) hashes.push(section.header.hash); + } + const hashScan = { + sources: hashes.length, + candidates: [...new Set(hashes)], + }; + const overlappingDataSymbols = dataHeaders.some((section, index) => + dataHeaders + .slice(0, index) + .some( + (previous) => + previous.offset < section.offset + section.size && + section.offset < previous.offset + previous.size, + ), + ); + if (sections.length === 0) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + `No recognizable Dart snapshot symbols were found: ${inventory.failures.map((failure) => failure.reason).join("; ")}`, + ), + ); + const pool = await projectStringPool(isolateData, signal); + const snapshotPartial = + inventory.failures.length > 0 || + aliasedDataSymbols || + overlappingDataSymbols || + sections.length < symbolNames.length || + dataHeaders.length !== SYMBOL_PAIR_COUNT || + dataHeaders.some((section) => !section.header.complete) || + hashScan.candidates.length !== 1 || + hashScan.sources !== SYMBOL_PAIR_COUNT; + const poolPartial = + isolateData === null || + pool.collectionCapped || + pool.packageUriCount > MAX_POOL_URIS || + pool.dartUriCount > MAX_POOL_URIS || + pool.classLikeTokenCount > MAX_POOL_TOKENS; + return ok( + createAnalysisExecution( + { + target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, + abi: selected.abi, + libapp: { + coverage: snapshotPartial ? "partial" : "complete", + bytes: imageLength, + sha256: imageSha256, + snapshot_hash: + hashScan.candidates.length === 1 ? hashScan.candidates[0]! : null, + snapshot_hash_candidates: [...hashScan.candidates], + snapshot_hash_sources: hashScan.sources, + sections, + }, + string_pool: { + coverage: poolPartial ? "partial" : "complete", + package_uris: pool.packageUris.slice(0, MAX_POOL_URIS), + package_uri_count_at_least: pool.packageUriCount, + dart_uris: pool.dartUris.slice(0, MAX_POOL_URIS), + dart_uri_count_at_least: pool.dartUriCount, + class_like_tokens: pool.classLikeTokens.slice(0, MAX_POOL_TOKENS), + class_like_token_count_at_least: pool.classLikeTokenCount, + printable_run_count: pool.printableRunCount, + }, + coverage: snapshotPartial || poolPartial ? "partial" : "complete", + }, + FLUTTER_PROVIDER_IDENTITY, + { + subject: { path, format: "apk", sha256: snapshot.sha256 }, + locations: [{ kind: "artifact-path", path }], + limitations: [ + "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", + "Snapshot sections are located through the ELF dynamic symbol table; a payload without exported snapshot symbols is refused rather than guessed", + "Snapshot hash sources are the headers at the start of the VM and isolate data symbols; complete decoding supports kind 3 (the Dart 2.14 FullAOT layout), a valid hash and a declared length that fits its symbol, including the four magic bytes; other kind values remain observed with partial coverage", + "String-pool entries are byte-pattern observations from the isolate data section, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser", + "class_like_tokens are identifier-shaped runs and include incidental matches", + "string-pool counts are lower bounds: collection stops at twice each projection bound, and any value beyond that is counted only through the capped flag", + ...inventory.failures.map((failure) => failure.reason), + ...(sections.length < symbolNames.length + ? [ + `Only ${String(sections.length)} of 4 snapshot symbols were present`, + ] + : []), + ...(aliasedDataSymbols + ? [ + "Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones", + ] + : []), + ...(overlappingDataSymbols && !aliasedDataSymbols + ? [ + "Snapshot data symbol ranges overlap; independent VM/isolate coverage is not established", + ] + : []), + ...dataHeaders + .filter((section) => !section.header.complete) + .map( + (section) => + `${section.name} has an incomplete or invalid snapshot header: kind ${String(section.header.kind)} (AOT app requires 3), declared length ${String(section.header.headerLength)} excludes the four magic bytes, symbol size ${String(section.size)}, valid hash ${String(section.header.hash !== null)}`, + ), + ...(pool.collectionCapped + ? [ + "String-pool collection or printable-run capacity was exceeded; counts are lower bounds and overflowed runs are omitted", + ] + : []), + ...(pool.dartUriCount > MAX_POOL_URIS + ? [ + `dart_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.dartUriCount)} distinct URIs`, + ] + : []), + ...(pool.packageUriCount > MAX_POOL_URIS + ? [ + `package_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.packageUriCount)} distinct URIs`, + ] + : []), + ...(pool.classLikeTokenCount > MAX_POOL_TOKENS + ? [ + `class_like_tokens reports ${String(MAX_POOL_TOKENS)} of at least ${String(pool.classLikeTokenCount)} distinct tokens`, + ] + : []), + ], + }, + ), + ); + } +} + +/** + * Categorize printable runs from one snapshot data section. Collection stops + * admitting new distinct entries once each category reaches its projection + * bound times a slack factor, so allocation and sorting stay proportional to + * the response rather than to an adversarial payload; counts still reflect + * how far scanning proceeded before the collection cap tripped. + */ +const projectStringPool = async ( + section: Buffer | null, + signal: AbortSignal | undefined, +): Promise<{ + packageUris: string[]; + packageUriCount: number; + dartUris: string[]; + dartUriCount: number; + classLikeTokens: string[]; + classLikeTokenCount: number; + printableRunCount: number; + collectionCapped: boolean; +}> => { + const packages = new Set(); + const darts = new Set(); + const tokens = new Set(); + let runCount = 0; + let collectionCapped = false; + const COLLECTION_SLACK = 2; + /** One printable run never materializes beyond this length. */ + const MAX_RUN_BYTES = 1024; + const packageBudget = MAX_POOL_URIS * COLLECTION_SLACK; + const dartBudget = MAX_POOL_URIS * COLLECTION_SLACK; + const tokenBudget = MAX_POOL_TOKENS * COLLECTION_SLACK; + if (section !== null) { + let run = ""; + const consider = (text: string): void => { + if (text.length < 5) return; + runCount += 1; + if (text.startsWith("package:")) { + if (!packages.has(text) && packages.size >= packageBudget) + collectionCapped = true; + else packages.add(text); + } else if (text.startsWith("dart:")) { + if (!darts.has(text) && darts.size >= dartBudget) + collectionCapped = true; + else darts.add(text); + } else if ( + text.length <= 128 && + text.length >= 5 && + IDENTIFIER_PATTERN.test(text) + ) { + if (!tokens.has(text) && tokens.size >= tokenBudget) + collectionCapped = true; + else tokens.add(text); + } + }; + let overflowed = false; + for (let start = 0; start < section.length; start += 64 * 1024) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + for (const byte of section.subarray(start, start + 64 * 1024)) { + if (byte >= 0x20 && byte < 0x7f) { + // A single unbounded run (an adversarial NUL-free section) never + // materializes: growth stops at the cap and the entire run is + // discarded at its delimiter, since a truncated prefix would be + // fabricated evidence rather than an observation. + if (run.length < MAX_RUN_BYTES) run += String.fromCharCode(byte); + else { + overflowed = true; + collectionCapped = true; + } + } else { + if (!overflowed) consider(run); + overflowed = false; + run = ""; + } + } + } + if (!overflowed) consider(run); + } + signal?.throwIfAborted(); + return { + packageUris: [...packages].sort(), + packageUriCount: packages.size, + dartUris: [...darts].sort(), + dartUriCount: darts.size, + classLikeTokens: [...tokens].sort(), + classLikeTokenCount: tokens.size, + printableRunCount: runCount, + collectionCapped, + }; +}; diff --git a/src/flutter/FlutterPayloadScan.test.ts b/src/flutter/FlutterPayloadScan.test.ts new file mode 100644 index 0000000000..681a373a10 --- /dev/null +++ b/src/flutter/FlutterPayloadScan.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; + +import { + LabeledStringScanner, + SnapshotHashScanner, +} from "./FlutterPayloadScan.js"; + +/** Real payload prefix from Flutter Gallery 2.9.2's arm64 libapp.so. */ +const realSnapshotSection = (hash: string): Uint8Array => + Uint8Array.from([ + 0xf5, + 0xf5, + 0xdc, + 0xdc, + 0x49, + 0x0d, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ...Array.from(hash, (character) => character.charCodeAt(0)), + ]); + +describe("SnapshotHashScanner", () => { + it("reads the hash from real vm and isolate sections that agree", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(realSnapshotSection("65817c30a78bb44c3dc3771876b6010a")); + scanner.push(Uint8Array.from([0, 0, 0])); + scanner.push(realSnapshotSection("65817c30a78bb44c3dc3771876b6010a")); + expect(scanner.result()).toEqual({ + capped: false, + sources: 2, + candidates: ["65817c30a78bb44c3dc3771876b6010a"], + }); + }); + + it("keeps both hashes when sections disagree", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(realSnapshotSection("11111111111111111111111111111111")); + scanner.push(realSnapshotSection("22222222222222222222222222222222")); + expect(scanner.result()).toEqual({ + capped: false, + sources: 2, + candidates: [ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ], + }); + }); + + it("survives chunk boundaries that split the magic and hash", () => { + const scanner = new SnapshotHashScanner(); + const payload = realSnapshotSection("65817c30a78bb44c3dc3771876b6010a"); + for (let offset = 0; offset < payload.length; offset += 7) + scanner.push(payload.subarray(offset, offset + 7)); + expect(scanner.result()).toEqual({ + capped: false, + sources: 1, + candidates: ["65817c30a78bb44c3dc3771876b6010a"], + }); + }); + + it("ignores a magic without a following hash", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(Uint8Array.from([0xf5, 0xf5, 0xdc, 0xdc, 0xff, 0xff, 0xff])); + expect(scanner.result()).toEqual({ + capped: false, + sources: 0, + candidates: [], + }); + }); +}); + +it("distinguishes a full candidate set from omitted conflicts", () => { + const scanner = new SnapshotHashScanner(); + for (let index = 0; index < 64; index += 1) + scanner.push(realSnapshotSection(index.toString(16).padStart(32, "0"))); + expect(scanner.result().capped).toBe(false); + scanner.push(realSnapshotSection("0".repeat(32))); + expect(scanner.result().capped).toBe(false); + scanner.push(realSnapshotSection("f".repeat(32))); + expect(scanner.result()).toMatchObject({ sources: 66, capped: true }); + expect(scanner.result().candidates).toHaveLength(64); +}); + +describe("LabeledStringScanner", () => { + const assertSingleToolchainLine = (line: string): void => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result().toolchainLines).toEqual([line]); + }; + it("collects a full clang line within the 500-character bound", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c1) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)", + ); + }); + + it("reads a labeled Dart VM version string when a build carries one", () => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from( + "\x00Dart VM version: 3.1.0 (stable) somewhere\x00", + (character) => character.charCodeAt(0), + ), + ), + ); + expect(scanner.result().dartVersion).toBe( + "Dart VM version: 3.1.0 (stable) somewhere", + ); + }); + + it("collects the labeled Android clang line", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc) (based on LLVM 9.0.8svn)", + ); + }); + + it("does not claim a toolchain cap for unrelated overlong strings", () => { + const scanner = new LabeledStringScanner(); + const license = `Apache License Version 2.0 ${"x".repeat(700)}`; + scanner.push( + Uint8Array.from( + Array.from(`\x00${license}\x00`, (character) => + character.charCodeAt(0), + ), + ), + ); + const result = scanner.result(); + expect(result.toolchainCapped).toBe(false); + expect(result.toolchainLines).toEqual([]); + }); + + it("claims the cap only for an overlong run shaped like a toolchain line", () => { + const overlongToolchain = `Android (${"r".repeat(700)}) clang version 9`; + for (const [text, toolchainCapped, dartVersionCapped] of [ + [overlongToolchain, true, false], + [`Dart VM version: ${"3".repeat(700)}`, false, true], + [`based on LLVM 9 ${"x".repeat(700)}`, true, false], + ] as const) { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${text}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result()).toMatchObject({ + toolchainCapped, + dartVersionCapped, + dartVersion: null, + toolchainLines: [], + }); + } + }); + + it.each([ + { label: "Dart", line: `Dart VM version:${"x".repeat(200)}`, dart: true }, + { + label: "Android clang", + line: `Android (${"r".repeat(80)}) clang version ${"x".repeat(500)}`, + dart: false, + }, + { label: "LLVM", line: `based on LLVM 9${"x".repeat(80)}`, dart: false }, + ])( + "requires the complete $label run at its own grammar boundary", + ({ line, dart }) => { + for (const suffix of ["", "x", "x".repeat(1000)]) { + const payload = Buffer.from(`${line}${suffix}\x00`); + for (const chunkSize of [1, 7, payload.length]) { + const scanner = new LabeledStringScanner(); + for (let offset = 0; offset < payload.length; offset += chunkSize) + scanner.push(payload.subarray(offset, offset + chunkSize)); + const valid = suffix === ""; + expect(scanner.result()).toEqual({ + dartVersion: dart && valid ? line : null, + dartVersionCapped: dart && !valid, + toolchainLines: !dart && valid ? [line] : [], + toolchainCapped: !dart && !valid, + }); + } + } + }, + ); + + it("preserves whitespace and separates distinct Dart-label omissions", () => { + assertSingleToolchainLine("based on LLVM 9 "); + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from("Dart VM version: 3.1\0Dart VM version: 3.2\0")); + expect(scanner.result()).toMatchObject({ + dartVersion: "Dart VM version: 3.1", + dartVersionCapped: true, + toolchainCapped: false, + }); + }); +}); diff --git a/src/flutter/FlutterPayloadScan.ts b/src/flutter/FlutterPayloadScan.ts new file mode 100644 index 0000000000..e1a69d5944 --- /dev/null +++ b/src/flutter/FlutterPayloadScan.ts @@ -0,0 +1,217 @@ +import { Buffer } from "node:buffer"; + +/** + * Streaming scanners for Flutter payload libraries inside an APK. + * + * All facts are observations of byte patterns at read time: the Dart + * snapshot hash sits 20 bytes after each snapshot magic, the GNU build-id + * is an ELF note, and version facts are labeled ASCII strings. Nothing is + * inferred or looked up externally. + */ + +/** First index of `needle` in `haystack` at or after `from`, or -1. */ +const findBytes = ( + haystack: Uint8Array, + needle: Uint8Array, + from: number, +): number => { + if (needle.length === 0) return from; + const last = haystack.length - needle.length; + for (let index = from; index <= last; index += 1) { + let matched = true; + for (let probe = 0; probe < needle.length; probe += 1) { + if (haystack[index + probe] !== needle[probe]) { + matched = false; + break; + } + } + if (matched) return index; + } + return -1; +}; + +/** Magic starting every Dart VM and isolate snapshot section. */ +export const DART_SNAPSHOT_MAGIC = Uint8Array.from([0xf5, 0xf5, 0xdc, 0xdc]); +export const SNAPSHOT_HASH_OFFSET = 20; +export const SNAPSHOT_HASH_LENGTH = 32; +export const SNAPSHOT_HASH_PATTERN = /^[0-9a-f]{32}$/u; + +export interface SnapshotHashScan { + /** Snapshot sections found. */ + readonly sources: number; + readonly capped: boolean; + /** Distinct 32-hex hashes following the magic, in first-seen order. */ + readonly candidates: readonly string[]; +} + +/** + * Incremental scanner for Dart snapshot magics and the hash that follows. + * Feed chunks in order; a trailing carry keeps a magic-to-hash span that a + * chunk boundary would otherwise split. + */ +/** Hard cap on distinct hashes retained before partial coverage reports. */ +export const MAX_SNAPSHOT_HASH_CANDIDATES = 64; + +export class SnapshotHashScanner { + #sources = 0; + #capped = false; + private readonly candidates: string[] = []; + private readonly candidateSet = new Set(); + #buffer = new Uint8Array(0); + /** Absolute stream offset from which unprocessed bytes begin. */ + #base = 0; + #absoluteScanFrom = 0; + + push(chunk: Uint8Array): void { + const joined = new Uint8Array(this.#buffer.length + chunk.length); + joined.set(this.#buffer, 0); + joined.set(chunk, this.#buffer.length); + // Resume after the last fully processed hash so carried bytes are not + // counted twice across chunk boundaries. + let offset = Math.max(0, this.#absoluteScanFrom - this.#base); + let pendingMagic: number | null = null; + for (;;) { + const magic = findBytes(joined, DART_SNAPSHOT_MAGIC, offset); + if (magic === -1) break; + if (magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH > joined.length) { + pendingMagic = magic; + break; + } + const candidate = Buffer.from( + joined.slice( + magic + SNAPSHOT_HASH_OFFSET, + magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH, + ), + ).toString("latin1"); + const isValidHash = SNAPSHOT_HASH_PATTERN.test(candidate); + if (isValidHash && !this.candidateSet.has(candidate)) { + if (this.candidates.length === MAX_SNAPSHOT_HASH_CANDIDATES) + this.#capped = true; + else { + this.candidateSet.add(candidate); + this.candidates.push(candidate); + } + } + // Only valid hash records count toward source agreement; a complete + // magic record whose field is not lowercase hex is not a source. + if (isValidHash) this.#sources += 1; + offset = magic + DART_SNAPSHOT_MAGIC.length; + this.#absoluteScanFrom = + this.#base + magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH; + } + // Keep a pending magic for the next chunk; otherwise keep only the + // trailing bytes where a magic could still begin, so magic-free + // streams never accumulate. + const keepFrom = + pendingMagic ?? + Math.max( + this.#absoluteScanFrom - this.#base, + joined.length - (DART_SNAPSHOT_MAGIC.length - 1), + ); + this.#buffer = joined.slice(keepFrom); + this.#base += keepFrom; + if (pendingMagic !== null) this.#absoluteScanFrom = this.#base; + } + + result(): SnapshotHashScan { + return { + sources: this.#sources, + candidates: [...this.candidates], + capped: this.#capped, + }; + } +} + +/** Labeled strings worth reporting when a build carries them. */ +export interface LabeledStringScan { + readonly dartVersion: string | null; + readonly dartVersionCapped: boolean; + readonly toolchainLines: readonly string[]; + /** True when further distinct matching lines were observed past the cap. */ + readonly toolchainCapped: boolean; +} + +const DART_VERSION_PATTERN = /^Dart VM version:[^\x00\n]{0,200}$/u; +const TOOLCHAIN_PATTERN = + /^(?:Android \([^)\x00\n]{0,80}\) clang version [^\x00\n]{0,500}|based on LLVM \d[^\x00\n]{0,80})$/u; +const MAX_TOOLCHAIN_LINES = 4; +/** + * Carry spans chunk boundaries for either accepted pattern. The toolchain + * pattern accepts up to 605 printable bytes (9 + 80 + 16 + 500), so the + * carry must hold at least that to preserve a line split at a boundary. + */ +const CARRY_BYTES = 640; + +/** + * Incremental ASCII string collector that keeps only labeled version and + * toolchain facts. A match is committed only when a printable run is + * terminated by a delimiter, so a value split across chunk boundaries is + * never committed truncated; the carry holds the longest accepted match + * so boundaries in either pattern survive. + */ +export class LabeledStringScanner { + #dartVersion: string | null = null; + #dartVersionCapped = false; + private readonly toolchainLines: string[] = []; + private carry = ""; + private overflowed = false; + #ended = false; + #toolchainCapped = false; + + push(chunk: Uint8Array): void { + for (const byte of chunk) { + if (byte >= 0x20 && byte < 0x7f) { + if (this.carry.length < CARRY_BYTES) + this.carry += String.fromCharCode(byte); + else this.overflowed = true; + } else { + this.#consider(this.carry); + this.carry = ""; + this.overflowed = false; + } + } + } + + /** Commit any retained tail, e.g. when the stream ends mid-string. */ + end(): void { + if (this.#ended) return; + this.#ended = true; + this.#consider(this.carry); + this.carry = ""; + this.overflowed = false; + } + + #consider(text: string): void { + // Keep a bounded prefix plus overflow state, never a sliding suffix. + // Every accepted label must consume its whole printable run so a + // pattern's shorter grammar cannot publish a fabricated prefix. + if (text.startsWith("Dart VM version:")) { + if (this.overflowed || !DART_VERSION_PATTERN.test(text)) + this.#dartVersionCapped = true; + else if (this.#dartVersion === null) this.#dartVersion = text; + else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + return; + } + if (!text.startsWith("Android (") && !text.startsWith("based on LLVM ")) + return; + if (this.overflowed || !TOOLCHAIN_PATTERN.test(text)) { + this.#toolchainCapped = true; + return; + } + const line = text; + if (this.toolchainLines.includes(line)) return; + if (this.toolchainLines.length < MAX_TOOLCHAIN_LINES) + this.toolchainLines.push(line); + else this.#toolchainCapped = true; + } + + result(): LabeledStringScan { + if (!this.#ended) this.end(); + return { + dartVersion: this.#dartVersion, + dartVersionCapped: this.#dartVersionCapped, + toolchainLines: [...this.toolchainLines], + toolchainCapped: this.#toolchainCapped, + }; + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 5a9fac0dbe..ec7a9f231a 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -75,6 +75,7 @@ import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnaly import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; import { registerAndroidTools } from "./registerAndroidTools.js"; import { registerApktoolTools } from "./registerApktoolTools.js"; +import { registerFlutterTools } from "./registerFlutterTools.js"; import { registerJebTools } from "./registerJebTools.js"; import { registerAdbTools } from "./registerAdbTools.js"; import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js"; @@ -82,8 +83,11 @@ import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js"; import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js"; import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js"; import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; import { createAndroidAnalysisProvider } from "../composition/android.js"; import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js"; +import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js"; import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js"; import { createJebAnalysisProvider } from "../composition/jeb.js"; import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js"; @@ -126,6 +130,7 @@ export interface CreateServerOptions { readonly webNetworkCapture?: WebNetworkCaptureService; readonly androidAnalysis?: AndroidAnalysisPort; readonly apktoolAnalysis?: ApktoolResourceAnalysisPort; + readonly flutterAnalysis?: FlutterBuildAnalysisPort; readonly jebAnalysis?: JebAnalysisPort; readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort; readonly browserObservation?: BrowserObservationPort; @@ -266,6 +271,8 @@ export const createServer = ( ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); + const flutter = + options.flutterAnalysis ?? createFlutterBuildAnalysisProvider(); const fridaInstrumentation = options.fridaInstrumentation ?? new FridaInstrumentationManager(); const wasmArtifact = wasmArtifactModule.create( @@ -287,6 +294,7 @@ export const createServer = ( apktool.close(), jeb.close(), adbDevice.close(), + flutter.close(), evmInterface.close(), webSourceLocation.close(), webModuleTrace.close(), @@ -374,6 +382,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerFlutterTools( + server, + new FlutterBuildAnalysisService(flutter), + toolLogger, + recordEvidence, + analysisAdmission, + ); registerApktoolTools( server, new ApktoolResourceAnalysisService(apktool), diff --git a/src/server/registerFlutterTools.ts b/src/server/registerFlutterTools.ts new file mode 100644 index 0000000000..5b601c5f7e --- /dev/null +++ b/src/server/registerFlutterTools.ts @@ -0,0 +1,35 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import type { Logger } from "pino"; +import { createContractToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind Flutter handlers to their exact named contracts through admission. */ +export const registerFlutterTools = ( + server: EvidenceMcpServer, + service: FlutterBuildAnalysisService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const handler = createContractToolHandler( + server, + service, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + for (const contract of [ + toolContract("identify_flutter_build"), + toolContract("inspect_dart_aot"), + ] as const) { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); + } +}; diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts new file mode 100644 index 0000000000..457dce96bc --- /dev/null +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { + aotFixtureImage, + engineFixtureImage, + REFERENCE_HASH, +} from "../../src/flutter/FlutterBuild.fixture.js"; +import { + createFlutterApk, + createDuplicateFlutterApk, +} from "../fixtures/flutterApk.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "CLI and stdio MCP retain Flutter identity, selected ABI and duplicate-member refusals", + async ({ cli }) => { + const image = aotFixtureImage(); + Buffer.from("Login\0").copy(image, 0x2300); + const engine = engineFixtureImage({ + notes: Buffer.from( + "040000000800000003000000474e55000123456789abcdef", + "hex", + ), + prefix: Buffer.from( + "040000000800000003000000474e55001111111111111111", + "hex", + ), + labels: ["Android (test) clang version 9.0.8"], + }); + const path = await createFlutterApk("selected target.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: image }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engine, + }, + { path: "lib/x86_64/libapp.so", bytes: image }, + { + path: "lib/x86_64/libflutter.so", + bytes: engine, + }, + ]); + const { call } = await connectLocalToolsMcp(); + for (const [name, abi] of [ + ["identify_flutter_build", undefined], + ["inspect_dart_aot", undefined], + ["inspect_dart_aot", "x86_64"], + ] as const) { + const result = await cli.run({ + arguments: [ + name.replaceAll("_", "-"), + path, + "--json", + ...(abi === undefined ? [] : ["--abi", abi]), + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract(name).outputSchema.parse(result.json); + const response = await call(name, { + path, + ...(abi === undefined ? {} : { abi }), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = parseEvidence(response.structuredContent); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + expect(mcp.subject).toEqual(evidence.subject); + expect(mcp.subject).toMatchObject({ + local_path: path, + format: "apk", + digest: { sha256: evidence.normalized_result.target.sha256 }, + }); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + if (name === "inspect_dart_aot") { + expect(evidence.normalized_result).toMatchObject({ + abi: abi ?? "arm64-v8a", + libapp: { + coverage: "complete", + snapshot_hash: REFERENCE_HASH, + sha256: createHash("sha256").update(image).digest("hex"), + }, + string_pool: { + coverage: "complete", + class_like_tokens: expect.arrayContaining(["Login"]), + }, + }); + } else { + const identified = toolContract( + "identify_flutter_build", + ).outputSchema.parse(result.json).normalized_result; + expect(identified.coverage).toBe("complete"); + for (const abi of identified.abis) { + expect(abi.libflutter).toMatchObject({ + build_id: "0123456789abcdef", + build_id_candidates: ["0123456789abcdef"], + }); + } + } + } + const duplicate = await createDuplicateFlutterApk(); + for (const name of [ + "identify_flutter_build", + "inspect_dart_aot", + ] as const) { + const result = await cli.run({ + arguments: [name.replaceAll("_", "-"), duplicate, "--json"], + }); + expect(result.exitCode).toBe(1); + const mcp = parseMcpToolError(await call(name, { path: duplicate })); + expect(mcp.error).toEqual(result.json); + expect(mcp.error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ message: expect.stringContaining(duplicate) }] }, + }); + } + }, +); + +cliTest( + "CLI and MCP retain unsupported snapshot kinds as partial evidence", + async ({ cli }) => { + const image = aotFixtureImage(); + for (const offset of [0x1000, 0x2000]) + image.writeBigUInt64LE(4n, offset + 12); + const path = await createFlutterApk("unsupported-kind.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: image }, + ]); + const result = await cli.run({ + arguments: ["inspect-dart-aot", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_dart_aot").outputSchema.parse( + result.json, + ); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_dart_aot", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(parseEvidence(response.structuredContent).normalized_result).toEqual( + evidence.normalized_result, + ); + expect(evidence.normalized_result).toMatchObject({ + coverage: "partial", + libapp: { coverage: "partial", snapshot_hash: REFERENCE_HASH }, + string_pool: { coverage: "complete" }, + }); + expect( + evidence.normalized_result.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .map((section) => section.kind), + ).toEqual([4, 4]); + }, +); diff --git a/tests/boundary/cli/flutterCli.test.ts b/tests/boundary/cli/flutterCli.test.ts new file mode 100644 index 0000000000..e9e32c99de --- /dev/null +++ b/tests/boundary/cli/flutterCli.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; + +import { serveCli } from "./cliServeHarness.js"; + +it.each(["identify-flutter-build", "inspect-dart-aot"])( + "%s advertises a schema through the public CLI", + async (command) => { + const result = await serveCli([command, "--schema", "--json"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toBeTruthy(); + }, +); + +it("reports an unreadable target with an input refusal", async () => { + const result = await serveCli([ + "identify-flutter-build", + "/nonexistent/app.apk", + "--json", + ]); + expect(result.stdout).toContain("invalid_request"); +}); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts new file mode 100644 index 0000000000..4a1c8248fe --- /dev/null +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -0,0 +1,1332 @@ +import { createReadStream } from "node:fs"; +import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { + aotFixtureImage, + engineFixtureImage, + buildElf64Image, + REFERENCE_HASH, +} from "../../../src/flutter/FlutterBuild.fixture.js"; +import { snapshotSection } from "../../../src/flutter/FlutterBuild.fixture.js"; +import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../../../src/domain/analysisErrorCore.js"; +import { + flutterResultSchemas, + type FlutterOperation, +} from "../../../src/domain/flutter/flutterBuildAnalysis.js"; +import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { + createFlutterApk as buildApk, + createDuplicateFlutterApk, +} from "../../fixtures/flutterApk.js"; +import * as regularFile from "../../../src/filesystem/RegularFile.js"; +import * as fsPromises from "node:fs/promises"; +import * as dartElf from "../../../src/flutter/DartElf.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, createReadStream: vi.fn(original.createReadStream) }; +}); +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original }; +}); + +const buildIdNote = (): Buffer => + Buffer.concat([ + Buffer.from([4, 0, 0, 0, 20, 0, 0, 0, 3, 0, 0, 0, 0x47, 0x4e, 0x55, 0]), + Buffer.from([ + 0xe8, 0xd9, 0x07, 0xef, 0x7c, 0x17, 0x5e, 0xc4, 0x31, 0x3f, 0x47, 0x11, + 0x10, 0x19, 0xad, 0xc9, 0x58, 0x8c, 0x5f, 0x61, + ]), + ]); + +const TOOLCHAIN_LINE = + "Android (5900059 based on r365631c) clang version 9.0.8 (based on LLVM 9.0.8svn)"; + +const libapp = aotFixtureImage; + +const libflutter = (): Buffer => + engineFixtureImage({ notes: buildIdNote(), labels: [TOOLCHAIN_LINE] }); + +/** + * Self-contained Dart AOT image: a synthetic ELF64 carrying the four + * snapshot symbols, two snapshot sections, and a string pool, so goldens + * do not depend on any file outside the repository. + */ +const aotImage = aotFixtureImage; + +/** Two data symbols at one offset and size: aliased exports. */ +const aliasedDataImage = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x1000, size: 52 }, + ]); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + snapshotSection(REFERENCE_HASH), + ]); +}; + +/** Two data symbols whose snapshot hashes disagree. */ +const conflictingHashImage = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x1200, size: 52 }, + ]); + const vm = snapshotSection("11111111111111111111111111111111"); + const isolate = snapshotSection("22222222222222222222222222222222"); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + vm, + Buffer.alloc(0x1200 - 0x1000 - vm.length), + isolate, + ]); +}; + +const analyzeImage = async (bytes: Buffer, operation: FlutterOperation) => { + const path = await buildApk("image.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (!result.ok) throw result.error; + return result.value; +}; + +it.each([32, 64] as const)( + "reads only ELF-owned notes in an ELF%s engine", + async (bits) => { + const fake = Buffer.from(buildIdNote()); + fake.fill(0x11, 16); + for (const ownership of ["program", "section", "both"] as const) { + const path = await buildApk("owned-notes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engineFixtureImage({ + bits, + ownership, + prefix: fake, + notes: buildIdNote(), + }), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("complete"); + expect(value.abis[0]!.libflutter).toMatchObject({ + build_id: "e8d907ef7c175ec4313f47111019adc9588c5f61", + build_id_candidates: ["e8d907ef7c175ec4313f47111019adc9588c5f61"], + }); + } + }, +); + +it.each(["conflicting", "truncated", "capacity"] as const)( + "preserves %s engine-note uncertainty", + async (shape) => { + const second = Buffer.from(buildIdNote()); + second.fill(0x11, 16); + const notes = + shape === "conflicting" + ? Buffer.concat([buildIdNote(), second]) + : shape === "truncated" + ? Buffer.concat([buildIdNote(), Buffer.from([1])]) + : Buffer.concat( + Array.from({ length: 65 }, (_, index) => { + const note = Buffer.from(buildIdNote()); + note.writeUInt32LE(index, 16); + return note; + }), + ); + const path = await buildApk("uncertain-notes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engineFixtureImage({ notes }), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]!.libflutter.build_id).toBeNull(); + expect(value.abis[0]!.libflutter.build_id_candidates).toHaveLength( + shape === "conflicting" ? 2 : shape === "truncated" ? 1 : 64, + ); + }, +); + +it.each([0, 1, 2, 4, 0xffff])( + "retains snapshot kind %s with partial decoding coverage", + async (kind) => { + const image = aotImage(); + image.writeBigUInt64LE(BigInt(kind), 0x1000 + 12); + image.writeBigUInt64LE(BigInt(kind), 0x2000 + 12); + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value).toMatchObject({ + coverage: "partial", + libapp: { coverage: "partial" }, + string_pool: { coverage: "complete" }, + }); + expect( + value.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .map((section) => section.kind), + ).toEqual([kind, kind]); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + }, +); + +it.each(["absent", "range", "metadata-budget"] as const)( + "preserves %s engine-note coverage at the ELF boundary", + async (shape) => { + const engine = engineFixtureImage({ + notes: + shape === "metadata-budget" + ? Buffer.alloc(8 * 1024 * 1024) + : Buffer.alloc(0), + }); + if (shape === "range") + engine.writeBigUInt64LE(BigInt(engine.length + 1), 64 + 56 + 8); + const path = await buildApk("note-bounds.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/arm64-v8a/libflutter.so", bytes: engine }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe(shape === "absent" ? "complete" : "partial"); + expect(value.abis[0]!.libflutter).toMatchObject({ + build_id: null, + build_id_candidates: [], + }); + if (shape !== "absent") + expect(result.value.limitations.join(" ")).toContain( + shape === "range" ? "not file-backed" : "metadata budget", + ); + }, +); + +it.each([ + ["root", "EACCES"], + ["open", "EPERM"], + ["write", "ENOSPC"], + ["close", "EIO"], + ["chmod", "EACCES"], + ["stat", "EIO"], + ["digest", "EACCES"], + ["zip-library", "EIO"], +] as const)( + "classifies owned snapshot %s failure (%s) separately from source input", + async (stage, code) => { + const path = await buildApk("owned-io.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const cause = Object.assign(new Error("owned snapshot failure"), { code }); + const native = + await vi.importActual( + "node:fs/promises", + ); + if (stage === "zip-library") + vi.spyOn(ZipArtifactReader.prototype, "open").mockRejectedValueOnce( + cause, + ); + if (stage === "root") + vi.spyOn(fsPromises, "mkdtemp").mockRejectedValueOnce(cause); + if (stage === "chmod") + vi.spyOn(fsPromises, "chmod").mockRejectedValueOnce(cause); + if (stage === "stat") + vi.spyOn(fsPromises, "stat").mockImplementation((...args) => { + if (args[0] !== path) return Promise.reject(cause); + return native.stat(...args); + }); + if (["open", "write", "close"].includes(stage)) + vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => { + if (args[1] === "wx" && stage === "open") throw cause; + const handle = await native.open(...args); + if (args[1] === "wx") { + if (stage === "write") + vi.spyOn(handle, "writeFile").mockRejectedValueOnce(cause); + if (stage === "close") + vi.spyOn(handle, "close").mockRejectedValueOnce(cause); + } + return handle; + }); + if (stage === "digest") + vi.mocked(createReadStream).mockImplementation( + () => + Readable.from( + (async function* () { + yield Buffer.alloc(0); + throw cause; + })(), + ) as ReturnType, + ); + onTestFinished(() => { + vi.restoreAllMocks(); + vi.mocked(createReadStream).mockReset(); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("expected owned I/O failure"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error.cause).toBe(cause); + expect(result.error.message).toContain( + "Provider-owned Flutter snapshot storage", + ); + }, +); + +it.each(["close", "elf", "pool", "cleanup"] as const)( + "honors cancellation during final %s work", + async (stage) => { + const path = await buildApk("late-cancellation.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const originalClose = OwnedFileHandle.prototype.close; + let closed = 0; + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation( + async function (this: OwnedFileHandle) { + await originalClose.call(this); + if (++closed === 4) { + if (stage === "close") controller.abort(); + if (stage === "elf") setImmediate(() => controller.abort()); + } + }, + ); + if (stage === "pool") { + const original = dartElf.readElfSymbols; + vi.spyOn(dartElf, "readElfSymbols").mockImplementation( + async (...args) => { + const value = await original(...args); + setImmediate(() => controller.abort()); + return value; + }, + ); + } + onTestFinished(() => { + vi.restoreAllMocks(); + }); + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + if (stage === "cleanup") controller.abort(); + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation: "inspect_dart_aot", input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("late cancellation must prevent success"); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + }, +); + +it("projects each payload shape through the owned snapshot", async () => { + const hash = REFERENCE_HASH; + const agreeBoth = await buildApk("agree.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + { path: "lib/x86_64/libapp.so", bytes: libapp(hash) }, + { path: "lib/x86_64/libflutter.so", bytes: libflutter() }, + ]); + const half = await buildApk("half.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) }, + ]); + const disagree = await buildApk("disagree.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: Buffer.concat([ + snapshotSection("11111111111111111111111111111111"), + snapshotSection("22222222222222222222222222222222"), + ]), + }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const plain = await buildApk("plain.apk", [ + { path: "classes.dex", bytes: Buffer.from("dex\n0123456789") }, + { path: "lib/arm64-v8a/libnative.so", bytes: Buffer.from("plain-elf") }, + ]); + const provider = new FlutterBuildProvider(); + const identify = async (apk: string) => { + const outcome = await provider.execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + if (!outcome.ok) throw new Error(outcome.error.message); + return outcome.value.result as { + flutter_detected: boolean; + coverage: string; + abis: { + abi: string; + libapp: { + present: boolean; + snapshot_hash: string | null; + snapshot_hash_sources: number; + snapshot_hash_candidates: string[]; + }; + libflutter: { present: boolean; build_id: string | null }; + }[]; + }; + }; + + const both = await identify(agreeBoth); + expect(both.flutter_detected).toBe(true); + expect(both.coverage).toBe("complete"); + expect(both.abis.map((abi) => abi.abi)).toEqual(["arm64-v8a", "x86_64"]); + for (const abi of both.abis) { + expect(abi.libapp.snapshot_hash).toBe(hash); + expect(abi.libapp.snapshot_hash_sources).toBe(2); + expect(abi.libflutter.build_id).toBe( + "e8d907ef7c175ec4313f47111019adc9588c5f61", + ); + } + + const engineless = await identify(half); + expect(engineless.coverage).toBe("partial"); + expect(engineless.abis[0]?.libapp.present).toBe(true); + expect(engineless.abis[0]?.libflutter.present).toBe(false); + + const conflicted = await identify(disagree); + expect(conflicted.coverage).toBe("partial"); + expect(conflicted.abis[0]?.libapp.snapshot_hash).toBeNull(); + expect(conflicted.abis[0]?.libapp.snapshot_hash_candidates).toEqual([ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ]); + + const negative = await identify(plain); + expect(negative.flutter_detected).toBe(false); + expect(negative.abis).toEqual([]); + expect(negative.coverage).toBe("complete"); +}); + +it("rejects a target that is not a readable file", async () => { + const projected = await (async () => { + const result = await new FlutterBuildProvider().execute({ + operation: "identify_flutter_build", + input: { path: "/nonexistent/app.apk" }, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a refusal"); + return projectAnalysisError(result.error); + })(); + expect(projected).toMatchObject({ code: "invalid_request" }); +}); + +it("refuses an APK without a Dart payload", async () => { + const apk = await buildApk("noflutter.apk", [ + { path: "classes.dex", bytes: Buffer.from("dex\n0123456789") }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + }); +}); + +it("refuses a requested ABI the payload does not carry", async () => { + const apk = await buildApk("oneabi.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk, abi: "x86_64" }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + }); + expect(result.error.message).toContain("arm64-v8a"); +}); + +it("inspects a full synthetic AOT image end to end", async () => { + const apk = await buildApk("realgallery.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + abi: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + sections: { name: string; magic_valid: boolean }[]; + }; + string_pool: { + package_uri_count_at_least: number; + dart_uri_count_at_least: number; + }; + coverage: string; + }; + expect(value.abi).toBe("arm64-v8a"); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(value.libapp.snapshot_hash_sources).toBe(2); + expect(value.libapp.sections.map((section) => section.name)).toEqual([ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ]); + expect( + value.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .every((section) => section.magic_valid), + ).toBe(true); + expect(value.string_pool.package_uri_count_at_least).toBe(2); + expect(value.string_pool.dart_uri_count_at_least).toBe(2); + // All four symbols present, both data magics valid, pool under budget: + // the honest answer for a complete payload is complete coverage. + expect(value.coverage).toBe("complete"); +}); + +it("reports aliased snapshot data symbols as partial coverage", async () => { + const apk = await buildApk("aliased.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aliasedDataImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + coverage: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + }; + }; + // The aliased range is scanned once, so one candidate no longer counts + // as two agreeing sources. + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash_sources).toBe(1); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(result.value.limitations).toContain( + "Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones", + ); +}); + +it("preserves conflicting snapshot hash candidates from the AOT image", async () => { + const apk = await buildApk("conflicted.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: conflictingHashImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + coverage: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + }; + }; + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash).toBeNull(); + expect(value.libapp.snapshot_hash_sources).toBe(2); + expect(value.libapp.snapshot_hash_candidates).toEqual([ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ]); +}); + +it("accepts an APK whose path contains spaces through the owned snapshot", async () => { + const apk = await buildApk("flutter spaced.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + expect(apk).toContain(" "); + const result = await new FlutterBuildProvider().execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { flutter_detected: boolean }; + expect(value.flutter_detected).toBe(true); +}); + +it("retains failed snapshot cleanup and retries it on close", async () => { + const apk = await buildApk("cleanup.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const spaces: string[] = []; + let failing = true; + const provider = new FlutterBuildProvider({ + removeRoot: async (path) => { + if (failing) throw new Error("cleanup refused"); + spaces.push(path); + await rm(path, { recursive: true, force: true }); + }, + }); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + expect(result.ok).toBe(true); + await expect(provider.close()).rejects.toThrow(/snapshot cleanup failed/u); + failing = false; + await provider.close(); + expect(spaces.length).toBeGreaterThanOrEqual(1); +}); + +it("cancels before creating any snapshot root", async () => { + const apk = await buildApk("cancel.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const controller = new AbortController(); + controller.abort(); + const provider = new FlutterBuildProvider(); + const result = await provider.execute( + { operation: "identify_flutter_build", input: { path: apk } }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + await provider.close(); +}); + +it.each([1, 20, 47, 48, 61])( + "identifies a valid ELF when ZIP chunks are only %i bytes", + async (chunkBytes) => { + const originalOpen = ZipArtifactReader.prototype.open; + const spy = vi + .spyOn(ZipArtifactReader.prototype, "open") + .mockImplementation(async function ( + this: ZipArtifactReader, + entry, + signal, + ) { + const source = await originalOpen.call(this, entry, signal); + return Readable.from( + (async function* () { + for await (const chunk of source) { + const bytes = chunk as Buffer; + for (let offset = 0; offset < bytes.length; offset += chunkBytes) + yield bytes.subarray(offset, offset + chunkBytes); + } + })(), + ); + }); + onTestFinished(() => spy.mockRestore()); + const execution = await analyzeImage(aotImage(), "identify_flutter_build"); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("complete"); + expect(value.abis[0]?.libapp).toMatchObject({ + elf_header_valid: true, + snapshot_hash: REFERENCE_HASH, + snapshot_hash_sources: 2, + }); + }, +); + +it.each([ + { + name: "zeroed offsets", + corrupt: (image: Buffer) => image.fill(0, 0x20, 0x30), + }, + { + name: "program table past EOF", + corrupt: (image: Buffer) => + image.writeBigUInt64LE(BigInt(image.length + 1), 0x20), + }, + { + name: "section table past EOF", + corrupt: (image: Buffer) => + image.writeBigUInt64LE(BigInt(image.length - 1), 0x28), + }, + { + name: "zero program entry width", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x36), + }, + { + name: "short section entry width", + corrupt: (image: Buffer) => image.writeUInt16LE(1, 0x3a), + }, + { + name: "absent program table", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x38), + }, + { + name: "unsupported class", + corrupt: (image: Buffer) => image.writeUInt8(3, 4), + }, + { + name: "unsupported byte order", + corrupt: (image: Buffer) => image.writeUInt8(2, 5), + }, + { + name: "unsafe table offset", + corrupt: (image: Buffer) => image.writeBigUInt64LE(1n << 63n, 0x20), + }, + { + name: "table inside header", + corrupt: (image: Buffer) => image.writeBigUInt64LE(1n, 0x20), + }, + { + name: "extended section count", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x3c), + }, +])( + "retains hash observations with partial coverage for $name", + async ({ corrupt }) => { + const image = aotImage(); + corrupt(image); + const execution = await analyzeImage(image, "identify_flutter_build"); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libapp).toMatchObject({ + elf_header_valid: false, + snapshot_hash: REFERENCE_HASH, + snapshot_hash_sources: 2, + }); + expect(execution.limitations.join("\n")).toContain( + "ELF header or table extent", + ); + }, +); + +it.each([0, 4, 20, 47, 61])( + "reports a %i-byte ELF prefix as partial without throwing", + async (length) => { + const execution = await analyzeImage( + aotImage().subarray(0, length), + "identify_flutter_build", + ); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libapp.elf_header_valid).toBe(false); + }, +); + +it("accepts a section-stripped ELF32 header and bounds its program table", async () => { + const image = Buffer.alloc(52 + 32 + 104); + image.writeUInt32LE(0x464c457f); + image.set([1, 1, 1], 4); // ELF32, little-endian, version 1 + image.writeUInt16LE(3, 0x10); // ET_DYN + image.writeUInt32LE(1, 0x14); + image.writeUInt32LE(52, 0x1c); + image.writeUInt16LE(52, 0x28); + image.writeUInt16LE(32, 0x2a); + image.writeUInt16LE(1, 0x2c); + image.writeUInt32LE(1, 52); // PT_LOAD + image.writeUInt32LE(image.length, 52 + 16); + snapshotSection(REFERENCE_HASH).copy(image, 84); + snapshotSection(REFERENCE_HASH).copy(image, 136); + const good = await analyzeImage(image, "identify_flutter_build"); + expect( + flutterResultSchemas.identify_flutter_build.parse(good.result).coverage, + ).toBe("complete"); + image.writeUInt32LE(image.length - 31, 0x1c); // One byte short of the table. + const bad = await analyzeImage(image, "identify_flutter_build"); + expect( + flutterResultSchemas.identify_flutter_build.parse(bad.result).coverage, + ).toBe("partial"); +}); + +it.each([0n, 15n, 47n, 49n, 1n << 63n])( + "retains malformed snapshot length %s as partial evidence", + async (length) => { + const image = aotImage(); + image.writeBigUInt64LE(length, 0x1004); + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + expect(value.libapp.sections[0]).toMatchObject({ + magic_valid: true, + header_length: + length > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(length), + }); + expect(execution.limitations.join("\n")).toContain( + "_kDartVmSnapshotData has an incomplete or invalid snapshot header", + ); + }, +); + +it("does not let two VM records replace a missing isolate header hash", async () => { + const image = aotImage(); + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 104 }, + { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, + { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, + { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, + ]); + elf.copy(image); + snapshotSection(REFERENCE_HASH).copy(image, 0x1000 + 52); + image[0x2000 + 20] = 0x78; // Keep the isolate magic but invalidate its hash. + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value.libapp.sections).toHaveLength(4); + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash_sources).toBe(1); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(execution.limitations.join("\n")).toContain( + "_kDartIsolateSnapshotData has an incomplete or invalid snapshot header", + ); +}); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s preserves an oversized APK refusal as a resource constraint", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-size-"); + const path = join(root, "oversized.apk"); + const file = await open(path, "wx"); + try { + await file.truncate(2 * 1024 ** 3 + 1); // Sparse file: no multi-GiB allocation. + } finally { + await file.close(); + } + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("expected a size refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error).toMatchObject({ + operation, + reportedLimits: { + max_apk_bytes: 2 * 1024 ** 3, + observed_bytes: 2 * 1024 ** 3 + 1, + }, + }); + }, +); + +it.each([ + { operation: "identify_flutter_build" }, + { operation: "inspect_dart_aot" }, +] as const)( + "preserves native stream aborts during $operation digest", + async ({ operation }) => { + const path = await buildApk("abort.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const nativeFs = await vi.importActual("node:fs"); + const streamMock = vi.mocked(createReadStream); + let abortCode: string | undefined; + streamMock.mockImplementation((streamPath, options) => { + const stream = nativeFs.createReadStream(streamPath, options); + if (streamPath !== path) { + stream.once("error", (cause: NodeJS.ErrnoException) => { + abortCode = cause.code; + }); + stream.once("readable", () => + controller.abort(new Error("caller requested stop")), + ); + } + return stream; + }); + onTestFinished(() => { + streamMock.mockReset(); + }); + const removedRoots: string[] = []; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + removedRoots.push(root); + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation, input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("expected cancellation"); + expect(abortCode).toBe("ABORT_ERR"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error).toMatchObject({ operation }); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(removedRoots).toHaveLength(1); + }, +); + +it.each([undefined, "arm64-v8a", "x86_64", "mips"])( + "applies the library cap only to selected ABI %s", + async (abi) => { + const path = await buildApk("mixed-sizes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/x86_64/libapp.so", bytes: aotImage() }, + ]); + const originalEntries = ZipArtifactReader.prototype.entries; + const inventory = vi + .spyOn(ZipArtifactReader.prototype, "entries") + .mockImplementation(async function* (this: ZipArtifactReader, signal) { + for await (const entry of originalEntries.call(this, signal)) + yield entry.path.includes("x86_64") + ? { ...entry, declaredSize: 256 * 1024 ** 2 + 1 } + : entry; + }); + const reads = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => { + inventory.mockRestore(); + reads.mockRestore(); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_dart_aot", + input: { path, ...(abi === undefined ? {} : { abi }) }, + }); + if (abi === "x86_64" || abi === "mips") { + if (result.ok) throw new Error("expected selected-ABI refusal"); + expect(projectAnalysisError(result.error).code).toBe( + abi === "x86_64" ? "resource_constraint" : "unsupported_target", + ); + expect(reads).not.toHaveBeenCalled(); + } else { + if (!result.ok) throw result.error; + expect( + flutterResultSchemas.inspect_dart_aot.parse(result.value.result).abi, + ).toBe("arm64-v8a"); + expect(reads.mock.calls.map(([entry]) => entry.path)).toEqual([ + "lib/arm64-v8a/libapp.so", + ]); + } + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s preserves a real ZIP metadata budget refusal", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-metadata-"); + const path = join(root, "large-directory.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + useWebWorkers: false, + }); + const comment = "x".repeat(65535); + for (let index = 0; index < 129; index += 1) + await writer.add( + `assets/empty-${String(index)}`, + new Uint8ArrayReader(new Uint8Array()), + { comment, level: 0 }, + ); + await writeFile(path, await writer.close()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("expected metadata capacity refusal"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error).toMatchObject({ + operation, + reportedLimits: { max_metadata_read_bytes: 8 * 1024 ** 2 }, + }); + expect(result.error.message).toContain("ZIP metadata read requires"); + }, +); + +it("refuses an AOT image whose snapshot names are all undefined imports", async () => { + const image = aotImage(); + const table = Number( + image.readBigUInt64LE(Number(image.readBigUInt64LE(0x28)) + 64 + 0x18), + ); + for (let index = 0; index < 4; index += 1) + image.writeUInt16LE(0, table + index * 24 + 6); + await expect(analyzeImage(image, "inspect_dart_aot")).rejects.toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + }); +}); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s retains a reader whose close failed before removing its root", + async (operation) => { + const path = await buildApk("reader-close.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const readers = new Set(); + const originalClose = ZipArtifactReader.prototype.close; + let failClose = true; + const close = vi + .spyOn(ZipArtifactReader.prototype, "close") + .mockImplementation(async function (this: ZipArtifactReader) { + readers.add(this); + if (failClose) throw new Error("file handle close refused"); + await originalClose.call(this); + }); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(async () => { + failClose = false; + await provider.close(); + for (const reader of readers) await originalClose.call(reader); + close.mockRestore(); + }); + const result = await provider.execute({ operation, input: { path } }); + if (!result.ok) throw result.error; + expect(removeRoot).not.toHaveBeenCalled(); + await expect(provider.close()).rejects.toThrow("snapshot cleanup failed"); + expect(removeRoot).not.toHaveBeenCalled(); + failClose = false; + await provider.close(); + expect(close).toHaveBeenCalledTimes(3); + expect(removeRoot).toHaveBeenCalledTimes(1); + }, +); + +it("reports omitted Dart labels independently of toolchain labels", async () => { + const path = await buildApk("dart-label.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: Buffer.concat([ + libflutter(), + Buffer.from(`Dart VM version:${"x".repeat(201)}\0`), + ]), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libflutter).toMatchObject({ + dart_version: null, + dart_version_capped: true, + toolchain_capped: false, + }); +}); + +it.each([0, 1, 2, 3])( + "retains failed local file-handle closure at acquisition stage %i", + async (stage) => { + const path = await buildApk("file-close.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + // Close order: snapshot writer, admitted source, extracted-image writer, image reader. + const owners: OwnedFileHandle[] = []; + const originalClose = OwnedFileHandle.prototype.close; + let failing = true; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + if (!owners.includes(this)) owners.push(this); + if (failing && owners[stage] === this) + throw new Error("handle close refused"); + await originalClose.call(this); + }); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(async () => { + failing = false; + await provider.close(); + for (const owner of owners) await originalClose.call(owner); + close.mockRestore(); + }); + const result = await provider.execute({ + operation: "inspect_dart_aot", + input: { path }, + }); + expect(result.ok).toBe(false); + expect(removeRoot).not.toHaveBeenCalled(); + await expect(provider.close()).rejects.toThrow("snapshot cleanup failed"); + failing = false; + await provider.close(); + expect(removeRoot).toHaveBeenCalledTimes(1); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s rejects duplicate real ZIP members before extracting either occurrence", + async (operation) => { + const path = await createDuplicateFlutterApk(); + const extract = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => extract.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("Duplicate libraries must be refused"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + }); + expect(JSON.stringify(projectAnalysisError(result.error))).toContain( + "duplicate Flutter library members", + ); + expect(JSON.stringify(projectAnalysisError(result.error))).toContain(path); + expect(extract).not.toHaveBeenCalled(); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s refuses ABI metadata expansion before opening empty libraries", + async (operation) => { + const path = await buildApk( + "many-abis.apk", + Array.from({ length: 33 }, (_, index) => ({ + path: `lib/abi_${String(index)}/libapp.so`, + bytes: Buffer.alloc(0), + })), + ); + const extract = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => extract.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("ABI expansion must be refused"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(extract).not.toHaveBeenCalled(); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s uses the selected path in malformed-APK diagnostics", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-malformed-"); + const path = join(root, "selected.apk"); + await writeFile(path, "not a ZIP"); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("Malformed APK must be refused"); + const error = projectAnalysisError(result.error); + expect(error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ message: expect.stringContaining(path) }] }, + }); + expect(JSON.stringify(error)).not.toContain("input.apk"); + }, +); + +it.each(["EACCES", "EPERM"] as const)( + "preserves %s at the shared source admission boundary", + async (code) => { + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockRejectedValue(Object.assign(new Error("denied"), { code })); + onTestFinished(() => admitted.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + for (const operation of [ + "identify_flutter_build", + "inspect_dart_aot", + ] as const) { + const result = await provider.execute({ + operation, + input: { path: "/selected.apk" }, + }); + if (result.ok) throw new Error("Denied read must fail"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + }); + } + }, +); + +it.each(["EACCES", "EPERM"] as const)( + "preserves %s during opened-source reads and path revalidation", + async (code) => { + const path = await buildApk("source-denied.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const original = regularFile.openRegularFile; + const cause = Object.assign(new Error("source denied"), { code }); + for (const stage of ["read", "revalidate"] as const) { + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await original(...args); + if (stage === "read") + vi.spyOn(handle, "read").mockRejectedValueOnce(cause); + return handle; + }); + const pathStat = + stage === "revalidate" + ? vi.spyOn(fsPromises, "stat").mockRejectedValueOnce(cause) + : undefined; + try { + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Denied source must fail"); + expect(projectAnalysisError(result.error).code).toBe("access_denied"); + expect(result.error.cause).toBe(cause); + } finally { + admitted.mockRestore(); + pathStat?.mockRestore(); + } + } + }, +); + +it("detects same-size source rewrites with restored mtime through the opened descriptor", async () => { + const path = await buildApk("changing.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const before = await stat(path); + const content = await readFile(path); + const nativeAdmission = regularFile.openRegularFile; + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await nativeAdmission(...args); + // Admission returns an open descriptor; mutate its file before the final + // descriptor stat, preserving the size and restoring the visible mtime. + const nativeStat = handle.stat.bind(handle); + let calls = 0; + vi.spyOn(handle, "stat").mockImplementation(async (...statArgs) => { + calls += 1; + if (calls === 2) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await writeFile(path, content); + await utimes(path, before.atime, before.mtime); + } + return nativeStat(...statArgs); + }); + return handle; + }); + onTestFinished(() => admitted.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Concurrent source rewrite must fail"); + expect(projectAnalysisError(result.error).code).toBe("artifact_changed"); +}); + +it("refuses truncated hash-candidate evidence and includes five-character tokens", async () => { + const image = aotImage(); + Buffer.from("Login\0").copy(image, 0x2300); + const aot = await analyzeImage(image, "inspect_dart_aot"); + expect( + flutterResultSchemas.inspect_dart_aot.parse(aot.result).string_pool + .class_like_tokens, + ).toContain("Login"); + const bytes = Buffer.concat( + Array.from({ length: 65 }, (_, index) => + snapshotSection(index.toString(16).padStart(32, "0")), + ), + ); + const path = await buildApk("many-hashes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Incomplete candidate set must be explicit"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); +}); + +it("stops a cancelled descriptor copy before writing its next chunk and releases every owner", async () => { + const path = await buildApk("copy-cancel.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const nativeAdmission = regularFile.openRegularFile; + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await nativeAdmission(...args); + const nativeRead = handle.read.bind(handle); + vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => { + const result = await nativeRead(...readArgs); + controller.abort(new Error("cancelled while copy read was pending")); + return result; + }); + return handle; + }); + onTestFinished(() => admitted.mockRestore()); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation: "identify_flutter_build", input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Cancelled copying must fail"); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(removeRoot).toHaveBeenCalledTimes(1); +}); diff --git a/tests/boundary/mcp/flutterMcp.test.ts b/tests/boundary/mcp/flutterMcp.test.ts new file mode 100644 index 0000000000..646f545113 --- /dev/null +++ b/tests/boundary/mcp/flutterMcp.test.ts @@ -0,0 +1,183 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; + +import type { FlutterBuildAnalysisPort } from "../../../src/application/flutter/FlutterBuildAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; + +const identity = { + id: "flutter", + name: "Flutter build identification", + version: null, +}; + +const SNAPSHOT_HASH = "65817c30a78bb44c3dc3771876b6010a"; + +/** One shared target identity for both fake operation results. */ +const target = { + path: "/targets/gallery.apk", + bytes: 117817550, + sha256: "a".repeat(64), +}; + +const inspectExecution = () => + createAnalysisExecution( + { + target, + abi: "arm64-v8a", + libapp: { + coverage: "complete", + bytes: 24691632, + sha256: "b".repeat(64), + snapshot_hash: SNAPSHOT_HASH, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash_sources: 2, + sections: [ + { + name: "_kDartVmSnapshotData", + offset: 4096, + size: 52, + magic_valid: true, + kind: 3, + header_length: 48, + }, + { + name: "_kDartIsolateSnapshotData", + offset: 8192, + size: 1536, + magic_valid: true, + kind: 3, + header_length: 48, + }, + ], + }, + string_pool: { + coverage: "complete", + package_uris: [ + "package:adaptive_breakpoints/src/adaptive_breakpoints.dart", + ], + package_uri_count_at_least: 1, + dart_uris: ["dart:_http"], + dart_uri_count_at_least: 1, + class_like_tokens: ["AdaptiveBreakpoint"], + class_like_token_count_at_least: 1, + printable_run_count: 4, + }, + coverage: "complete", + }, + identity, + ); + +const identifyExecution = () => + createAnalysisExecution( + { + target, + flutter_detected: true, + abis: [ + { + abi: "arm64-v8a", + libapp: { + present: true, + bytes: 24691632, + sha256: "b".repeat(64), + elf_header_valid: true, + snapshot_hash_sources: 2, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash: SNAPSHOT_HASH, + }, + libflutter: { + present: true, + bytes: 9073432, + sha256: "c".repeat(64), + build_id: "e8d907ef7c175ec4313f47111019adc9588c5f61", + build_id_candidates: ["e8d907ef7c175ec4313f47111019adc9588c5f61"], + dart_version: null, + dart_version_capped: false, + toolchain_lines: ["Android (5900059) clang version 9.0.8"], + toolchain_capped: false, + }, + }, + ], + coverage: "complete", + }, + identity, + ); + +/** A recording-free provider answering both Flutter operations inline. */ +const fakeProvider = (): FlutterBuildAnalysisPort => ({ + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + return ok( + request.operation === "inspect_dart_aot" + ? inspectExecution() + : identifyExecution(), + ); + }, +}); + +it("advertises an exact valid Flutter schema and records inline evidence through MCP", async () => { + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { flutterAnalysis: fakeProvider() }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "flutter-boundary", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + + const { tools } = await client.listTools(); + assertAdvertisedMcpContracts( + ["identify_flutter_build", "inspect_dart_aot"], + tools, + ); + + const identified = await client.callTool({ + name: "identify_flutter_build", + arguments: { path: "/targets/gallery.apk" }, + }); + expect(identified.isError).not.toBe(true); + const text = (identified.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + const evidence = parseEvidence(JSON.parse(text)); + expect(evidence.normalized_result).toMatchObject({ + flutter_detected: true, + abis: [ + { + abi: "arm64-v8a", + libapp: { snapshot_hash: SNAPSHOT_HASH }, + }, + ], + }); + + const inspected = await client.callTool({ + name: "inspect_dart_aot", + arguments: { path: "/targets/gallery.apk", abi: "arm64-v8a" }, + }); + expect(inspected.isError).not.toBe(true); + const inspectedText = (inspected.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + const inspectedEvidence = parseEvidence(JSON.parse(inspectedText)); + expect(inspectedEvidence.normalized_result).toMatchObject({ + abi: "arm64-v8a", + libapp: { + snapshot_hash: SNAPSHOT_HASH, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash_sources: 2, + }, + }); +}); diff --git a/tests/fixtures/flutterApk.ts b/tests/fixtures/flutterApk.ts new file mode 100644 index 0000000000..068dee929a --- /dev/null +++ b/tests/fixtures/flutterApk.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { aotFixtureImage } from "../../src/flutter/FlutterBuild.fixture.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +/** Materialize real ZIP members with test-scoped exact-path cleanup. */ +export const createFlutterApk = async ( + name: string, + files: ReadonlyArray<{ readonly path: string; readonly bytes: Buffer }>, +): Promise => { + const root = await createTestTempDirectory("rea-flutter-"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + useWebWorkers: false, + }); + for (const file of files) + await writer.add(file.path, new Uint8ArrayReader(file.bytes)); + const path = join(root, name); + await writeFile(path, await writer.close()); + return path; +}; + +/** ZIP permits repeated names; the writer refuses them, so rewrite both records. */ +export const createDuplicateFlutterApk = async (): Promise => { + const path = await createFlutterApk("duplicates.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + { path: "lib/arm64-v8a/libxxx.so", bytes: Buffer.alloc(64) }, + ]); + const bytes = await readFile(path); + const from = Buffer.from("lib/arm64-v8a/libxxx.so"); + for ( + let at = bytes.indexOf(from); + at >= 0; + at = bytes.indexOf(from, at + from.length) + ) + bytes.write("lib/arm64-v8a/libapp.so", at); + await writeFile(path, bytes); + return path; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 716b3b0e51..74dbf53056 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,7 +51,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS, From 51d6c87cdbf5f11eaf3cb1862befe704a81827c5 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:51:40 +0800 Subject: [PATCH 02/93] feat(clients): register ZCode during setup (#1564) * feat(clients): register ZCode during setup - SupportedClients: zcode definition (~/.zcode/cli/config.json, format zcode, personal skills at ~/.zcode/skills) - ClientConfigurationDocument: zcode dialect reads/writes the nested mcp.servers table; withClientServers now tolerates a missing outer table for fresh documents - docs: add ZCode to the supported-clients table with configuration notes - tests: zcodeClientConfiguration.test.ts (path resolution, nested upsert, fresh/empty configs, uninstall) Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/zcodeClientConfiguration.test.ts 6/6. * fix(clients): preserve ZCode fallback servers and honor enable flag Review follow-up (#1564), two integration gaps: - ZCode reads the user-scope ~/.agents/mcp.json fallback only while the native .zcode config defines no servers at all, with no merging. Setup now copies the fallback's mcpServers entries into the native table when writing the first native server, so registrations that only live in the fallback keep loading; the fallback file is left untouched. A malformed fallback fails setup closed (configure and preflight) instead of being silently suppressed. - ZCode disables a server with "enable": false on its entry, which the generic status parsing (disabled/enabled) did not recognize: doctor reported such a registration as aligned although ZCode never starts it. The ZCode dialect now treats enable: false as disabled (state stale) and setup rewrites the entry on rerun. Docs describe both behaviors; the bundled REA skill's supported client list includes zcode. Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/zcodeClientConfiguration.test.ts 10/10; npm run check:fast green; npm run verify:test-discovery 733 files. * fix(clients): reject non-regular ZCode fallback files before reading Resolve the fallback through the client-config transaction path and the regular-file admission before parsing it, so a FIFO or other non-regular file at ~/.agents/mcp.json fails setup closed instead of blocking the read indefinitely. Directory and FIFO fallback cases are covered in tests, and the preflight remediation names unreadable fallbacks too. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 20 + docs/installation.md | 1 + .../ClientConfigurationDocument.ts | 47 +- src/application/ClientRegistrationStatus.ts | 4 +- src/application/SetupClientConfiguration.ts | 99 ++++- src/application/SupportedClients.ts | 17 + .../filesystem/clientLifecycle.test.ts | 1 + .../setupClientConfigCleanupOwnership.test.ts | 60 +++ .../zcodeClientConfiguration.test.ts | 401 ++++++++++++++++++ 10 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 tests/boundary/filesystem/zcodeClientConfiguration.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index 9b06a93fc1..dbddb0eb9d 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, -`hermes`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 640f559eb8..16ba81400d 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -145,6 +145,26 @@ REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +## ZCode + +For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the +user-level `~/.zcode/cli/config.json`. Entries carry `command`, `args`, and +`env` without a transport discriminator. ZCode discovers personal skills from +`~/.zcode/skills` rather than the shared directory, so setup installs its +skill copy there. + +ZCode reads the user-scope `~/.agents/mcp.json` fallback only while the native +`.zcode` config defines no servers at all, with no merging. When setup writes +the first native server into an empty table, it copies the fallback's +`mcpServers` entries into the native table so they keep loading, and leaves +the fallback file untouched. Existing REA environment overrides are preserved +when refreshing its command. A malformed or unreadable fallback file fails setup instead of +being silently suppressed; repair or remove it and rerun. ZCode disables a +server with `enable: false` on its entry; doctor reports such a registration +as stale, and rerunning setup removes the flag to repair it. Native configuration +uses strict JSON; setup refuses comments, trailing commas and a BOM rather than +writing a registration ZCode cannot load. + ## DeepSeek Harness DeepSeek Harness registers MCP clients through a Cordis YAML patch sequence diff --git a/docs/installation.md b/docs/installation.md index 181b75c929..69e800f05a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,6 +280,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| ZCode | `zcode` | | DeepSeek Harness | `dsh` | `dsh` is main-only until a release after 6.4.0. The registry check above does diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 27b5393d23..cecbd440ab 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -86,6 +86,7 @@ export const clientConfigurationServersKey = ( case "hermes": return "mcp_servers"; case "opencode": + case "zcode": return "mcp"; case "vscode": return "servers"; @@ -116,6 +117,8 @@ const parseDocument = ( if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. if (format === "hermes") return objectSchema.parse(parseYaml(text)); + // ZCode loads nonempty native files with strict JSON.parse. + if (format === "zcode") return objectSchema.parse(JSON.parse(text)); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; @@ -142,6 +145,23 @@ const requireObject = ( }); }; +/** ZCode reads the same-scope fallback as strict JSON with a mcpServers table. */ +export const parseZcodeFallbackConfiguration = ( + text: string, +): ClientConfigurationDocument => { + const document = requireObject(JSON.parse(text), "ZCode fallback root"); + return { + document, + servers: + document.mcpServers === undefined + ? {} + : requireObject(document.mcpServers, "ZCode fallback mcpServers"), + serversPath: ["mcpServers"], + dialect: "json", + legacyServers: {}, + }; +}; + /** Members of OpenCode V2's `mcp` object that are not server names. */ const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]); @@ -169,6 +189,21 @@ export const parseClientConfiguration = ( const value = document[serversKey]; const servers = value === undefined ? {} : requireObject(value, `"${serversKey}"`); + // ZCode reads stdio servers from the nested `mcp.servers` table of + // `~/.zcode/cli/config.json`; entries carry `command`, `args`, and `env`. + if (format === "zcode") { + const nested = servers.servers; + return { + document, + servers: + nested === undefined + ? {} + : requireObject(nested, `"${serversKey}.servers"`), + serversPath: [serversKey, "servers"], + dialect: format, + legacyServers: {}, + }; + } const native = servers.servers; // A V1 server named `servers` has a string `type` discriminator; the V2 // table may instead hold a server object named `type`. @@ -228,8 +263,16 @@ export const withClientServers = ( : editDshDocument(parsed); const [key = "", nested] = parsed.serversPath; if (nested === undefined) return { ...parsed.document, [key]: servers }; - const outer = { ...objectSchema.parse(parsed.document[key]) }; - if (removeLegacy !== undefined && !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy)) + // A fresh document may not hold the outer table yet (for example a new + // ZCode `config.json` without an `mcp` object), so treat absence as empty. + const outerValue = parsed.document[key]; + const outer = + outerValue === undefined ? {} : { ...objectSchema.parse(outerValue) }; + if ( + parsed.dialect === "opencode_v2" && + removeLegacy !== undefined && + !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy) + ) delete outer[removeLegacy]; return { ...parsed.document, [key]: { ...outer, [nested]: servers } }; }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index dc246d5e6c..e2696e088b 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -226,7 +226,9 @@ const registrationAligned = ( const command = [registration.command, ...registration.args]; if ( (client.format !== "grok" && registration.disabled === true) || - (registration.enabled === false && !forcedEnabled) + (registration.enabled === false && !forcedEnabled) || + // ZCode's native flag is `enable`; other dialects leave that field opaque. + (client.format === "zcode" && registration.enable === false) ) return false; if (!isOwnedClientRegistrationCommand(command, currentCommandPath)) diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a4f4e837e5..bfab80cbf1 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -1,5 +1,9 @@ import { readClientPolicyBlock } from "./GeminiClientSettings.js"; -import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js"; +import { + readClientConfigurationFiles, + readConfigurationText, +} from "./ClientConfigurationFiles.js"; +import { err, ok, type Result } from "../domain/result.js"; import { clientRegistrationEntry, clientConfigurationValuesEqual, @@ -8,6 +12,7 @@ import { legacyClientServerPath, OMP_DISABLED_SERVERS_KEY, parseClientConfiguration, + parseZcodeFallbackConfiguration, serializeClientConfiguration, withClientServers, type ClientConfigurationDocument, @@ -120,12 +125,15 @@ const configureClientDocument = async ( void cause; return { status: "failed", reason: "readback" }; } - const desired = clientConfigurationDesired( - client, - environment, - command, - parsed, - ); + const fallback = await readZcodeFallbackServers(client, parsed); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "failed", reason: "readback" }; + } + const desired = clientConfigurationDesired(client, environment, command, { + ...parsed, + servers: { ...fallback.value, ...parsed.servers }, + }); if (registrationCurrent(parsed, desired)) return { status: "unchanged" }; return persistClientConfiguration({ client, @@ -134,6 +142,7 @@ const configureClientDocument = async ( original, parsed, desired, + fallbackServers: fallback.value, }); }; @@ -144,6 +153,7 @@ interface ClientConfigurationUpdate { readonly original: string | undefined; readonly parsed: ClientConfigurationDocument; readonly desired: unknown; + readonly fallbackServers: Readonly>; } const persistClientConfiguration = async ({ @@ -153,6 +163,7 @@ const persistClientConfiguration = async ({ original, parsed, desired, + fallbackServers, }: ClientConfigurationUpdate): Promise => { const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`; @@ -169,7 +180,11 @@ const persistClientConfiguration = async ({ ); const registered = withClientServers( parsed, - { ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired }, + { + ...fallbackServers, + ...parsed.servers, + [PRODUCT_IDENTITY.mcpServerKey]: desired, + }, PRODUCT_IDENTITY.mcpServerKey, ); // OMP's denylist would hide the registration; keep every other listed name. @@ -194,6 +209,9 @@ const persistClientConfiguration = async ({ await writeFileAtomic( transactionPath, serializeClientConfiguration(document, format, original, [ + ...Object.keys(fallbackServers) + .filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey) + .map((name) => clientServerPath(parsed, name)), clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey), ...(legacyPath === undefined ? [] : [legacyPath]), ...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []), @@ -213,7 +231,14 @@ const persistClientConfiguration = async ({ await readRegularFileText(transactionPath), format, ); - if (!registrationCurrent(readback, desired)) { + if ( + !registrationCurrent(readback, desired) || + Object.entries(fallbackServers).some( + ([name, entry]) => + name !== PRODUCT_IDENTITY.mcpServerKey && + !clientConfigurationValuesEqual(readback.servers[name], entry), + ) + ) { await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } @@ -329,7 +354,14 @@ export const inspectClientConfiguration = async ( original = await readRegularFileText(transactionPath); } catch (cause: unknown) { if (cause instanceof RegularFileCleanupFailure) throw cause; - if (isMissing(cause)) return { status: "create" }; + if (isMissing(cause)) { + const fallback = await readZcodeFallbackServers(client); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "invalid", remediation: fallback.error.detail }; + } + return { status: "create" }; + } return { status: "invalid", remediation: `The configuration file could not be read. Check its permissions before rerunning setup. ${cause instanceof Error ? cause.message : String(cause)}`, @@ -359,6 +391,11 @@ export const inspectClientConfiguration = async ( ); if (registrationCurrent(parsed, desired)) return { status: "already_current" }; + const fallback = await readZcodeFallbackServers(client, parsed); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "invalid", remediation: fallback.error.detail }; + } } catch (cause: unknown) { if (cause instanceof RegularFileCleanupFailure) throw cause; // Malformed configuration is reported with the invalid remediation. @@ -382,6 +419,48 @@ const throwCleanupFailure = (error: ClientConfigurationFileError): void => { if (error.cleanupFailure !== undefined) throw error.cleanupFailure; }; +/** Admit the fallback only when the first native ZCode server would suppress it. */ +const readZcodeFallbackServers = async ( + client: SetupClient, + parsed?: ClientConfigurationDocument, +): Promise< + Result>, ClientConfigurationFileError> +> => { + if ( + client.format !== "zcode" || + (parsed !== undefined && Object.keys(parsed.servers).length > 0) + ) + return ok({}); + const path = client.zcodeFallbackPath; + const detail = `The ZCode fallback configuration ${path ?? "path"} is unresolved, malformed or unreadable. Repair it before rerunning setup: the first native server would suppress that fallback.`; + if (path === undefined) + return err({ kind: "unreadable", path: client.configPath, detail }); + const transactionPath = await resolveClientConfigTransactionPath(path); + if (transactionPath === undefined) + return err({ kind: "unreadable", path, detail }); + const source = await readConfigurationText(transactionPath); + if (!source.ok) + return err({ ...source.error, detail: `${detail} ${source.error.detail}` }); + if (source.value === undefined) return ok({}); + try { + const parsed = parseZcodeFallbackConfiguration(source.value); + const environment = clientRegistrationEnvironment(parsed); + if (!environment.ok) + return err({ + kind: "malformed", + path, + detail: `${detail} ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}`, + }); + return ok(parsed.servers); + } catch (cause: unknown) { + return err({ + kind: "malformed", + path, + detail: `${detail} ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + const preserveConfigBackup = async ( source: string, destination: string, diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index b44e5017ab..5c74bb972e 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -26,6 +26,8 @@ export interface SetupClient { }; /** Unresolved path evidence only; consumers must not perform I/O on it. */ readonly configPathError?: string; + /** Same-scope fallback ZCode stops loading after its first native server. */ + readonly zcodeFallbackPath?: string; readonly markerPath?: string; readonly format?: | "json" @@ -38,6 +40,7 @@ export interface SetupClient { | "omp" | "pi" | "hermes" + | "zcode" | "dsh" | "unsupported"; } @@ -64,6 +67,7 @@ interface ClientDefinition { readonly configPath: ClientPath; readonly markerPath: ClientPath; readonly format: NonNullable; + readonly zcodeFallbackPath?: (context: ClientPathContext) => string; readonly skillPath?: ClientPath; readonly configPaths?: (context: ClientPathContext) => readonly string[]; readonly geminiSettings?: ( @@ -499,6 +503,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: hermesDirectory, format: "hermes", }, + { + name: "zcode", + zcodeFallbackPath: ({ home }: ClientPathContext) => + join(home, ".agents", "mcp.json"), + displayName: "ZCode", + configPath: [".zcode", "cli", "config.json"], + markerPath: [".zcode", "cli"], + skillPath: [".zcode", "skills"], + format: "zcode", + }, { name: "dsh", displayName: "DeepSeek Harness", @@ -590,6 +604,9 @@ export const supportedClients = ( ...new Set([...definition.configPaths(context), configPath]), ], }), + ...(definition.zcodeFallbackPath === undefined + ? {} + : { zcodeFallbackPath: definition.zcodeFallbackPath(context) }), markerPath: marker.value, format: definition.format, }; diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 89c4dc95ae..caf1549493 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -69,6 +69,7 @@ describe("client configuration filesystem lifecycle", () => { "omp", "pi", "hermes", + "zcode", "dsh", "grok_bot", ]); diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index 19a1c77d54..3bf33c3128 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -31,6 +31,66 @@ beforeEach(async () => { afterEach(() => openMock.mockReset()); +describe("ZCode fallback cleanup ownership", () => { + it.each(["preflight", "configure"])( + "retains a failed ZCode fallback close during %s until host cleanup", + async (operation) => { + const home = await createTestTempDirectory("rea-zcode-fallback-close-"); + const host = systemSetupHost( + { platform: "linux", homeDirectory: home } as DoctorHost, + { HOME: home }, + ); + const client = (await host.supportedClients()).find( + ({ name }) => name === "zcode", + ); + if (client?.zcodeFallbackPath === undefined) + throw new Error("ZCode fallback path missing"); + const fallback = client.zcodeFallbackPath; + await mkdir(dirname(fallback), { recursive: true }); + const original = '{"mcpServers":{"other":{"command":"other"}}}\n'; + await writeFile(fallback, original); + const closeAttempts: object[] = []; + let allowClose = false; + openMock.mockImplementation(async (...args) => { + const handle = await actualFs.open(...args); + if (String(args[0]) !== fallback) return handle; + const nativeClose = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closeAttempts.push(handle); + if (!allowClose) throw new Error("held fallback handle"); + await nativeClose(); + }); + return handle; + }); + try { + const action = + operation === "preflight" + ? host.inspectClientConfiguration?.(client, {}, ["rea", "mcp"]) + : host.configureClient(client, {}, ["rea", "mcp"]); + if (action === undefined) throw new Error("setup preflight missing"); + await expect(action).rejects.toMatchObject({ + _tag: "ConfigurationError", + cause: expect.any(RegularFileCleanupFailure), + cleanup: { resources: [fallback] }, + }); + expect(closeAttempts).toHaveLength(2); + expect(closeAttempts[0]).toBe(closeAttempts[1]); + expect(await readFile(fallback, "utf8")).toBe(original); + await expect(readFile(client.configPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + allowClose = true; + expect(await host.close?.()).toBeUndefined(); + expect(closeAttempts).toHaveLength(3); + expect(closeAttempts[2]).toBe(closeAttempts[0]); + } finally { + allowClose = true; + await host.close?.(); + } + }, + ); +}); + describe("setup client configuration cleanup ownership", () => { it("stops client configuration and retries the same alignment handle from host close", async () => { const home = await createTestTempDirectory("rea-setup-config-close-"); diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts new file mode 100644 index 0000000000..4b00a2e012 --- /dev/null +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -0,0 +1,401 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const zcodePath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "zcode"); + +describe("ZCode configuration paths", () => { + it("resolves the user config file and marker directory", () => { + expect(zcodePath("/home/a")).toEqual({ + name: "zcode", + displayName: "ZCode", + configPath: join("/home/a", ".zcode", "cli", "config.json"), + markerPath: join("/home/a", ".zcode", "cli"), + format: "zcode", + zcodeFallbackPath: join("/home/a", ".agents", "mcp.json"), + }); + expect(zcodePath("/Users/a", "darwin")?.configPath).toBe( + join("/Users/a", ".zcode", "cli", "config.json"), + ); + expect(zcodePath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".zcode", "cli", "config.json"), + ); + }); + + it("resolves the personal skills directory beside the config root", () => { + expect( + skillDestinations("/home/a", ["zcode"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".zcode", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +const zcodeClient = async () => { + const home = await createTestTempDirectory("rea-zcode-"); + const client = zcodePath(home, process.platform); + if (client?.markerPath === undefined) throw new Error("missing ZCode client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; +}; + +describe("ZCode JSON registration", () => { + it("upserts rea into mcp.servers while preserving unrelated settings", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + `{ + "mcp": { + "rea": { "unrelated": true }, + "servers": { + "other": { "command": "other-server" } + } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.rea).toEqual({ unrelated: true }); + expect(parsed.mcp.servers.other).toEqual({ command: "other-server" }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcp.servers table in an existing config without servers", async () => { + const { client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify({ ui: { theme: "dark" } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.ui).toEqual({ theme: "dark" }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty config file", async () => { + const { client } = await zcodeClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcp: { servers: { other: { command: "other-server" } } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.other).toEqual({ command: "other-server" }); + expect(parsed.mcp.servers.rea).toBeUndefined(); + }); +}); + +const fallbackPath = (home: string) => join(home, ".agents", "mcp.json"); + +describe("ZCode .agents fallback preservation", () => { + it.each([undefined, '{"ui":{"theme":"dark"}}\n', '{"mcp":{"servers":{}}}\n'])( + "migrates fallback servers with native config %s", + async (original) => { + const { home, client } = await zcodeClient(); + if (original !== undefined) await writeFile(client.configPath, original); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallbackPath(home), + JSON.stringify( + { + mcpServers: { + other: { command: "other-server", env: { TOKEN: "x" } }, + rea: { + command: "old-rea", + args: ["mcp"], + env: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + GHIDRA_INSTALL_DIR: "/caller/ghidra", + CALLER_CHOICE: "keep-me", + }, + }, + }, + }, + null, + 2, + ), + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.other).toEqual({ + command: "other-server", + env: { TOKEN: "x" }, + }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + env: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + GHIDRA_INSTALL_DIR: "/caller/ghidra", + CALLER_CHOICE: "keep-me", + }, + }); + if (original !== undefined) { + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + if (JSON.parse(original).ui !== undefined) + expect(parsed.ui).toEqual({ theme: "dark" }); + } + // The fallback file stays in place; ZCode ignores it from now on. + expect( + JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers.other, + ).toEqual({ command: "other-server", env: { TOKEN: "x" } }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + }, + ); + + it("leaves an existing native table alone instead of merging the fallback", async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallbackPath(home), + JSON.stringify( + { mcpServers: { shared: { command: "fallback-server" } } }, + null, + 2, + ), + ); + await writeFile( + client.configPath, + JSON.stringify( + { mcp: { servers: { existing: { command: "existing-server" } } } }, + null, + 2, + ), + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.existing).toEqual({ + command: "existing-server", + }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + expect(parsed.mcp.servers.shared).toBeUndefined(); + }); +}); + +describe("ZCode fallback admission", () => { + it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])( + "refuses malformed fallback REA environment %j before native writes", + async (env) => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + const original = JSON.stringify({ + mcpServers: { rea: { command: "old-rea", env } }, + }); + await writeFile(fallbackPath(home), original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "invalid", + remediation: expect.stringContaining( + Object.keys(env)[0] ?? "environment", + ), + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await readFile(fallbackPath(home), "utf8")).toBe(original); + }, + ); + + it("fails closed when the fallback configuration is malformed", async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile(fallbackPath(home), "{ not json"); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect(await inspectClientConfiguration(client, {}, command)).toMatchObject( + { status: "invalid" }, + ); + }); + + it("fails closed when the fallback path is not a regular file", async () => { + const { home, client } = await zcodeClient(); + await mkdir(fallbackPath(home), { recursive: true }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect(await inspectClientConfiguration(client, {}, command)).toMatchObject( + { status: "invalid" }, + ); + }); + + it.skipIf(process.platform === "win32")( + "rejects a FIFO fallback without blocking on it", + async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await promisify(execFile)("mkfifo", [fallbackPath(home)]); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ status: "invalid" }); + }, + ); +}); + +describe("ZCode disabled registrations", () => { + it("reports enable: false as stale and repairs it on setup", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify( + { + mcp: { + servers: { + rea: { + command: command[0], + args: command.slice(1), + enable: false, + }, + }, + }, + }, + null, + 2, + ), + ); + const statusFor = async () => + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"); + expect(await statusFor()).toMatchObject({ state: "stale" }); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await statusFor()).toMatchObject({ state: "aligned", command }); + }); +}); + +describe("ZCode strict native JSON", () => { + it.each(['{ // comment\n "mcp": {}}', '{"mcp":{},}', "\uFEFF{}"])( + "rejects unreadable native syntax without modifying %s", + async (original) => { + const { home, client } = await zcodeClient(); + await writeFile(client.configPath, original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ status: "invalid" }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + await expect( + readFile(`${client.configPath}.rea.backup`), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ state: "invalid" }); + }, + ); +}); From bfa805decf83744bd3ecb0d1402667cf3ed5c3b2 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:53:30 +0800 Subject: [PATCH 03/93] feat(clients): register Trae during setup (#1573) * feat(clients): register Trae during setup - SupportedClients: trae definition (VS Code-style user directory, User/mcp.json, format json) - Trae documents mcpServers as the top-level server table with command/args/env and stdio as the default transport; it has no personal skills directory, so setup skips the skill copy - tests: traeClientConfiguration.test.ts (per-platform user directory resolution, upsert preserving unrelated settings, fresh/empty configs, uninstall) Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/traeClientConfiguration.test.ts 6/6. * fix(clients): install Trae's personal REA skill Review follow-up (#1573): the Trae Skills documentation lists global skills under ~/.trae/skills on macOS/Linux and %userprofile%/.trae/skills on Windows, so the definition now carries that skillPath and setup installs the bundled skill copy there. The installation docs no longer claim Trae has no personal skills directory, and the bundled REA skill's supported client list includes trae. Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/traeClientConfiguration.test.ts 7/7. * docs(clients): qualify Trae configuration path evidence --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 15 ++ docs/installation.md | 1 + src/application/SupportedClients.ts | 25 +++ .../filesystem/clientLifecycle.test.ts | 1 + .../traeClientConfiguration.test.ts | 173 ++++++++++++++++++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/traeClientConfiguration.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index dbddb0eb9d..ae8cd4a98b 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -45,7 +45,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, -`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, +`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`, `hermes`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 16ba81400d..01bde02a44 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -145,6 +145,21 @@ REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +## Trae + +For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes +`mcpServers.rea` into the user-scope `mcp.json` under Trae's VS Code-style +user directory (`%APPDATA%\Trae\User` on Windows, +`~/Library/Application Support/Trae/User` on macOS, and +`~/.config/Trae/User` elsewhere). Entries carry `command`, `args`, and +`env`; stdio is the default transport. Trae discovers personal skills from +`~/.trae/skills` (`%userprofile%\.trae\skills` on Windows), so setup +installs its skill copy there. + +The user-level MCP paths follow the editor's VS Code layout. Trae's official +documentation describes the raw JSON editor without specifying those paths; +loading this registration in Trae remains unverified. + ## ZCode For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the diff --git a/docs/installation.md b/docs/installation.md index 69e800f05a..6b55e316a6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,6 +280,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| Trae | `trae` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 5c74bb972e..3969aa8485 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -91,6 +91,22 @@ const vscodeUserDirectory = ({ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User"); }; +const traeUserDirectory = ({ + home, + platform, + env, +}: ClientPathContext): string => { + if (platform === "win32") + return join( + env.APPDATA ?? join(home, "AppData", "Roaming"), + "Trae", + "User", + ); + if (platform === "darwin") + return join(home, "Library", "Application Support", "Trae", "User"); + return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "Trae", "User"); +}; + const claudeDesktopDirectory = ({ home, platform, @@ -464,6 +480,15 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: vscodeUserDirectory, format: "vscode", }, + { + name: "trae", + displayName: "Trae", + skillPath: [".trae", "skills"], + configPath: (context: ClientPathContext) => + join(traeUserDirectory(context), "mcp.json"), + markerPath: traeUserDirectory, + format: "json", + }, { name: "grok_build", displayName: "Grok Build", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index caf1549493..05efa0bfba 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -65,6 +65,7 @@ describe("client configuration filesystem lifecycle", () => { "commandcode", "qwen_code", "vscode", + "trae", "grok_build", "omp", "pi", diff --git a/tests/boundary/filesystem/traeClientConfiguration.test.ts b/tests/boundary/filesystem/traeClientConfiguration.test.ts new file mode 100644 index 0000000000..202220e525 --- /dev/null +++ b/tests/boundary/filesystem/traeClientConfiguration.test.ts @@ -0,0 +1,173 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const traePath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "trae"); + +describe("Trae configuration paths", () => { + it("resolves the VS Code-style user directory per platform", () => { + expect(traePath("/home/a", "linux", {})?.configPath).toBe( + join("/home/a", ".config", "Trae", "User", "mcp.json"), + ); + expect( + traePath("/home/a", "linux", { XDG_CONFIG_HOME: "/xdg" })?.configPath, + ).toBe(join("/xdg", "Trae", "User", "mcp.json")); + expect(traePath("/Users/a", "darwin")?.configPath).toBe( + join( + "/Users/a", + "Library", + "Application Support", + "Trae", + "User", + "mcp.json", + ), + ); + expect( + traePath("C:\\Users\\a", "win32", { + APPDATA: "C:\\Users\\a\\AppData\\Roaming", + })?.configPath, + ).toBe(join("C:\\Users\\a\\AppData\\Roaming", "Trae", "User", "mcp.json")); + expect( + traePath("C:\\Users\\a", "win32", { + APPDATA: "C:\\Users\\a\\AppData\\Roaming", + })?.markerPath, + ).toBe(join("C:\\Users\\a\\AppData\\Roaming", "Trae", "User")); + }); + + it("resolves the registration format as the plain mcpServers JSON dialect", () => { + expect(traePath("/home/a")?.format).toBe("json"); + }); + + it("resolves the personal skills directory under the home root", () => { + expect( + skillDestinations("/home/a", ["trae"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".trae", "skills", "reverse-engineer-anything"), + ]); + expect( + skillDestinations("C:\\Users\\a", ["trae"], {}, "win32").map( + ({ path }) => path, + ), + ).toEqual([ + join("C:\\Users\\a", ".trae", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +describe("Trae JSON registration", () => { + const traeClient = async () => { + const home = await createTestTempDirectory("rea-trae-"); + const client = traePath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing Trae client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await traeClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "trae"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing mcp.json without servers", async () => { + const { client } = await traeClient(); + await writeFile( + client.configPath, + JSON.stringify({ unrelated: { setting: true } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.unrelated).toEqual({ setting: true }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty mcp.json", async () => { + const { client } = await traeClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await traeClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +}); From 109769319a4a3a5f85e8a43bb419d04aefff66a4 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:55:55 +0800 Subject: [PATCH 04/93] feat(clients): register Qoder CLI during setup (#1572) * feat(clients): register Qoder CLI during setup - SupportedClients: qoder definition (~/.qoder/settings.json, format json, QODER_CONFIG_DIR override honored) - Qoder CLI documents mcpServers as the user-scope server table with command/args/env and stdio as the default transport; no personal skills directory is documented, so setup skips the skill copy - tests: qoderClientConfiguration.test.ts (path resolution and QODER_CONFIG_DIR override, upsert preserving unrelated settings, fresh/empty configs, uninstall) Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/qoderClientConfiguration.test.ts 6/6. * fix(clients): install Qoder CLI's personal REA skill Review follow-up (#1572): the Qoder CLI Skills documentation lists ~/.qoder/skills/{skill-name}/SKILL.md with automatic discovery, so the definition now carries skillPath following Qoder's configuration location (QODER_CONFIG_DIR honored) and setup installs the bundled skill copy there. The installation docs no longer claim Qoder has no personal skills directory, and the bundled REA skill's supported client list includes qoder. Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/qoderClientConfiguration.test.ts 7/7. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 9 + docs/installation.md | 1 + src/application/SupportedClients.ts | 13 ++ .../filesystem/clientLifecycle.test.ts | 1 + .../qoderClientConfiguration.test.ts | 166 ++++++++++++++++++ tests/fixtures/clientEnvironment.ts | 1 + 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/qoderClientConfiguration.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index ae8cd4a98b..ed8393bd9a 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`, -`hermes`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 01bde02a44..c0e629d66c 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -145,6 +145,15 @@ REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +## Qoder CLI + +For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes +`mcpServers.rea` into the user-level `~/.qoder/settings.json` (the +`QODER_CONFIG_DIR` override is honored). Entries carry `command`, `args`, and +`env`; stdio is the default transport. Qoder CLI discovers personal skills +from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup +installs its skill copy there. + ## Trae For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes diff --git a/docs/installation.md b/docs/installation.md index 6b55e316a6..e4a8b0e299 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,6 +280,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| Qoder | `qoder` | | Trae | `trae` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 3969aa8485..18c3480d9a 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -128,6 +128,9 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME || join(home, ".codex"); +const qoderDirectory = ({ home, env }: ClientPathContext): string => + env.QODER_CONFIG_DIR || join(home, ".qoder"); + /** Match Qwen Code's home override, including tilde and cwd-relative paths. */ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const override = env.QWEN_HOME; @@ -528,6 +531,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: hermesDirectory, format: "hermes", }, + { + name: "qoder", + displayName: "Qoder", + skillPath: (context: ClientPathContext) => + join(qoderDirectory(context), "skills"), + configPath: (context: ClientPathContext) => + join(qoderDirectory(context), "settings.json"), + markerPath: qoderDirectory, + format: "json", + }, { name: "zcode", zcodeFallbackPath: ({ home }: ClientPathContext) => diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 05efa0bfba..2d814a5f36 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -70,6 +70,7 @@ describe("client configuration filesystem lifecycle", () => { "omp", "pi", "hermes", + "qoder", "zcode", "dsh", "grok_bot", diff --git a/tests/boundary/filesystem/qoderClientConfiguration.test.ts b/tests/boundary/filesystem/qoderClientConfiguration.test.ts new file mode 100644 index 0000000000..0621dfdc08 --- /dev/null +++ b/tests/boundary/filesystem/qoderClientConfiguration.test.ts @@ -0,0 +1,166 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const qoderPath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "qoder"); + +describe("Qoder configuration paths", () => { + it("resolves the user settings file and marker directory", () => { + expect(qoderPath("/home/a")).toEqual({ + name: "qoder", + displayName: "Qoder", + configPath: join("/home/a", ".qoder", "settings.json"), + markerPath: join("/home/a", ".qoder"), + format: "json", + }); + expect(qoderPath("/Users/a", "darwin")?.configPath).toBe( + join("/Users/a", ".qoder", "settings.json"), + ); + expect(qoderPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".qoder", "settings.json"), + ); + }); + + it("honors the QODER_CONFIG_DIR override", () => { + expect( + qoderPath("/home/a", "linux", { QODER_CONFIG_DIR: "/opt/qoder" }) + ?.configPath, + ).toBe(join("/opt/qoder", "settings.json")); + expect( + qoderPath("/home/a", "linux", { QODER_CONFIG_DIR: "" })?.configPath, + ).toBe(join("/home/a", ".qoder", "settings.json")); + }); + + it("resolves the personal skills directory beside the config root", () => { + expect( + skillDestinations("/home/a", ["qoder"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".qoder", "skills", "reverse-engineer-anything"), + ]); + expect( + skillDestinations("/home/a", ["qoder"], { + QODER_CONFIG_DIR: "/opt/qoder", + }).map(({ path }) => path), + ).toEqual([join("/opt/qoder", "skills", "reverse-engineer-anything")]); + }); +}); + +describe("Qoder JSON registration", () => { + const qoderClient = async () => { + const home = await createTestTempDirectory("rea-qoder-"); + const client = qoderPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing Qoder client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await qoderClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + }, + "model": { "name": "qoder-model" } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.model).toEqual({ name: "qoder-model" }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "qoder"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing settings file without servers", async () => { + const { client } = await qoderClient(); + await writeFile( + client.configPath, + JSON.stringify({ general: { vimMode: false } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.general).toEqual({ vimMode: false }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty settings file", async () => { + const { client } = await qoderClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await qoderClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 35fc6843ce..9825caeec0 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -34,6 +34,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "PI_PROFILE", + "QODER_CONFIG_DIR", "QWEN_HOME", "SAND_DATA_ROOT", "XDG_CONFIG_HOME", From 86de439ba4c9297f721abc60c715fe30fb6ff3df Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:00:59 -0700 Subject: [PATCH 05/93] fix(frida): name the argument each Frida input error rejects (#2217) Frida session lookups, script lookups, unreadable script files and the instrument-with-frida PID and script checks returned invalid_request with no issues, while the remediation told callers to correct the listed arguments. Attach the session_id, script_id, path, target or script issue with the reason so CLI and MCP callers can see what to fix. Fixes #2213 Co-authored-by: Claude Opus 5.5 --- .../frida/FridaInstrumentationManager.test.ts | 36 +++++++++++++++++ src/application/frida/fridaInputErrors.ts | 27 +++++++++++++ src/cli/fridaCommands.ts | 25 +++++++++++- src/frida/FridaInstrumentationManager.ts | 24 ++++++++--- src/server/registerFridaTools.ts | 3 +- tests/boundary/cli/fridaCli.test.ts | 40 +++++++++++++++++++ 6 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 src/application/frida/fridaInputErrors.ts diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index 8c315c0d5c..29ba97965c 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -986,3 +986,39 @@ it("serializes script loading against concurrent close and unloads the loaded sc expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true); expect(bindings.device.session.isDetached()).toBe(true); }); + +it("names the unknown session or script ID it rejects", async () => { + const { manager, started } = await startLocalAttachedSession(); + if (!started.ok) throw started.error; + const missing = "00000000-0000-4000-8000-000000000009"; + const sessionIssue = { + path: ["session_id"], + reason: "invalid_value", + message: `No open Frida session ${missing}; start one with start_frida_session.`, + }; + for (const result of [ + await manager.closeSession(missing), + await manager.resumeSession(missing), + await manager.loadScript(missing, { sourceKind: "inline", source: "" }), + await manager.unloadScript(missing, missing), + ]) + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError", issues: [sessionIssue] }, + }); + expect( + await manager.unloadScript(started.value.sessionId, missing), + ).toMatchObject({ + ok: false, + error: { + issues: [ + { + path: ["script_id"], + reason: "invalid_value", + message: `No loaded Frida script ${missing} in this session.`, + }, + ], + }, + }); + await manager.closeAll(); +}); diff --git a/src/application/frida/fridaInputErrors.ts b/src/application/frida/fridaInputErrors.ts new file mode 100644 index 0000000000..9dc65d5103 --- /dev/null +++ b/src/application/frida/fridaInputErrors.ts @@ -0,0 +1,27 @@ +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; + +/** A session ID that names no open Frida session owned by this process. */ +export const unknownFridaSession = ( + operation: string, + sessionId: string, +): AnalysisInputError => + new AnalysisInputError(operation, undefined, [ + { + path: ["session_id"], + reason: "invalid_value", + message: `No open Frida session ${sessionId}; start one with start_frida_session.`, + }, + ]); + +/** A script ID that names no loaded script in the selected session. */ +export const unknownFridaScript = ( + operation: string, + scriptId: string, +): AnalysisInputError => + new AnalysisInputError(operation, undefined, [ + { + path: ["script_id"], + reason: "invalid_value", + message: `No loaded Frida script ${scriptId} in this session.`, + }, + ]); diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index 23dec3d9c2..8f3b90efbe 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -220,7 +220,18 @@ export const registerFridaCommands = ( (options.scriptFile === undefined) ) return projectAnalysisError( - new AnalysisInputError(CLI_COMMANDS.instrumentWithFrida), + new AnalysisInputError( + CLI_COMMANDS.instrumentWithFrida, + undefined, + [ + { + path: ["script"], + reason: "invalid_value", + message: + "Provide exactly one of --script or --script-file.", + }, + ], + ), ); const remote = remoteFrom(options); const selected = @@ -248,7 +259,17 @@ export const registerFridaCommands = ( }; if (targetInput === undefined) return projectAnalysisError( - new AnalysisInputError(CLI_COMMANDS.instrumentWithFrida), + new AnalysisInputError( + CLI_COMMANDS.instrumentWithFrida, + undefined, + [ + { + path: ["target"], + reason: "invalid_value", + message: "attach mode needs a positive integer process ID.", + }, + ], + ), ); const result = await withProviderCleanup(() => service.instrument( diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 0f40c72742..406258e5a7 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -31,6 +31,10 @@ import { import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + unknownFridaScript, + unknownFridaSession, +} from "../application/frida/fridaInputErrors.js"; const MAX_SESSION_CAPTURE_BYTES = 16 * 1024 * 1024; const MAX_MESSAGE_METADATA_BYTES = 128 * 1024; @@ -334,7 +338,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ): Promise> { const managed = this.#sessions.get(sessionId); if (managed === undefined || managed.state === "detached") - return err(new AnalysisInputError("load_frida_script")); + return err(unknownFridaSession("load_frida_script", sessionId)); let scriptSource: string; try { @@ -354,7 +358,13 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { cause.code, { cause }, ) - : new AnalysisInputError("load_frida_script", { cause }), + : new AnalysisInputError("load_frida_script", { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${sourcePath ?? ""}.`, + }, + ]), ); } @@ -419,7 +429,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ): Promise> { const managed = this.#sessions.get(sessionId); if (managed === undefined || managed.state === "detached") - return err(new AnalysisInputError("resume_frida_session")); + return err(unknownFridaSession("resume_frida_session", sessionId)); if (managed.mode !== "spawn" || managed.state !== "paused") return err( new AnalysisCapabilityUnavailableError( @@ -454,8 +464,10 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { ): Promise> { const managed = this.#sessions.get(sessionId); const entry = managed?.scripts.get(scriptId); - if (managed === undefined || entry === undefined) - return err(new AnalysisInputError("unload_frida_script")); + if (managed === undefined) + return err(unknownFridaSession("unload_frida_script", sessionId)); + if (entry === undefined) + return err(unknownFridaScript("unload_frida_script", scriptId)); const cleanupReason = await this.#releaseScript(managed, entry); return cleanupReason === undefined ? ok(null) @@ -536,7 +548,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { > { const managed = this.#sessions.get(sessionId); if (managed === undefined) - return err(new AnalysisInputError("close_frida_session")); + return err(unknownFridaSession("close_frida_session", sessionId)); const scriptFailures = new Map(); const cleanupFailures: string[] = []; let parentSessionReleased = diff --git a/src/server/registerFridaTools.ts b/src/server/registerFridaTools.ts index cf314e6e35..5125f105ce 100644 --- a/src/server/registerFridaTools.ts +++ b/src/server/registerFridaTools.ts @@ -12,6 +12,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { ToolResultDelivery } from "./toolResult.js"; +import { unknownFridaSession } from "../application/frida/fridaInputErrors.js"; /** Register Frida device discovery and persistent instrumentation operations. */ export const registerFridaTools = ( @@ -199,7 +200,7 @@ export const registerFridaTools = ( return delivery.toCallToolResult( { ok: false as const, - error: new AnalysisInputError(status.name), + error: unknownFridaSession(status.name, input.session_id), }, status, ); diff --git a/tests/boundary/cli/fridaCli.test.ts b/tests/boundary/cli/fridaCli.test.ts index 427a34781a..317ca62152 100644 --- a/tests/boundary/cli/fridaCli.test.ts +++ b/tests/boundary/cli/fridaCli.test.ts @@ -234,3 +234,43 @@ it("preserves observed devices when CLI provider cleanup fails", async () => { details: { diagnostics: { partial_observation: { devices } } }, }); }); + +it.each([ + [ + ["attach", "abc", "--script", "send(1)"], + "target", + "attach mode needs a positive integer process ID.", + ], + [ + ["attach", "71"], + "script", + "Provide exactly one of --script or --script-file.", + ], +])( + "names the rejected instrument-with-frida argument for %j", + async (argv, path, message) => { + let starts = 0; + const provider = { + async startSession() { + starts += 1; + throw new Error("Frida must not start for invalid input"); + }, + } as unknown as FridaInstrumentationPort; + const cli = createCli({}, undefined, provider); + let stdout = ""; + await cli.serve(["instrument-with-frida", ...argv, "--json"], { + env: {}, + exit: () => undefined, + stdout: (text) => { + stdout += text; + }, + }); + expect(JSON.parse(stdout)).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: [path], reason: "invalid_value", message }], + }, + }); + expect(starts).toBe(0); + }, +); From ae412654d5947f3841c191e92f3ad6b73d6f894b Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:09:11 +0800 Subject: [PATCH 06/93] docs: preserve the native guide title anchor (#2224) Keep the former native-ui-and-dispatch-investigation fragment after the page title was changed in #2207. The old and current fragments resolve to the same heading position in the generated page. Validation: VitePress production build, generated-document checks, format and link checks, and real Chrome navigation to both fragments. Refs #2156. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/native-investigation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 3627379ad6..27381246b4 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1,3 +1,5 @@ +
+ # Native binary investigation Use a native provider to discover functions, inspect instructions and pseudocode, From c9aecde580d26d4e7745c2623a4efa327a27367e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:39 -0700 Subject: [PATCH 07/93] fix(frida): reject an unreadable script file before starting a session (#2211) * fix(frida): reject an unreadable script file before starting a session The shared instrument_with_frida workflow started the Frida session, which attaches or spawns the target, before the provider read a file script source. A missing script file then failed loadScript with a bare invalid_request, and session cleanup resumed the paused spawn, so the target ran without instrumentation. Check that the selected file is a readable regular file first and report the path, or access_denied for EACCES/EPERM, before any device effect. Fixes #2209 Co-Authored-By: Claude Opus 5.5 * fix(frida): let cancellation win over the script file check An already-aborted request with an unreadable file source returned the input or access-denied error instead of AnalysisCancelledError. Check the signal before the script file preflight and again after it, so file sources keep the existing cancellation semantics. Co-Authored-By: Claude Opus 5.5 * refactor(frida): keep the pre-start checks out of instrument Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../frida/FridaInstrumentationService.test.ts | 63 ++++++++++++++++++ .../frida/FridaInstrumentationService.ts | 64 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index 9f53310ab4..c1251f9a52 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -1,3 +1,6 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { expect, it } from "vitest"; import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js"; @@ -102,6 +105,66 @@ it.each(["startSession", "instrument"] as const)( }, ); +it.each([ + [join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"], + [tmpdir(), "it is not a regular file"], +])( + "rejects the unreadable script file %s before starting Frida", + async (path, reason) => { + let starts = 0; + const provider = createProvider({ + async startSession() { + starts += 1; + return { ok: true, value: createSession() }; + }, + }); + const service = new FridaInstrumentationService(provider); + + const result = await service.instrument({ + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { sourceKind: "file", path }, + durationMs: 1, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisInputError); + expect((result.error as AnalysisInputError).issues).toEqual([ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${path}: ${reason}.`, + }, + ]); + expect(starts).toBe(0); + }, +); + +it("reports cancellation before checking a file script source", async () => { + const service = new FridaInstrumentationService(createProvider()); + const controller = new AbortController(); + controller.abort(new Error("cancelled before call")); + + const result = await service.instrument( + { + mode: "spawn", + deviceId: "local", + program: "/tmp/fixture", + source: { + sourceKind: "file", + path: join(tmpdir(), "rea-missing-frida-script.js"), + }, + durationMs: 1, + }, + controller.signal, + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); +}); + it("does not resume a spawned target when cancellation arrives during script loading", async () => { const controller = new AbortController(); let resumes = 0; diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index 9e9e85931e..c359850d66 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -1,11 +1,15 @@ +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { + AnalysisAccessDeniedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { @@ -19,6 +23,64 @@ import type { StartFridaSessionInput, } from "./FridaInstrumentationPort.js"; +const unreadableScriptFile = async ( + source: FridaScriptSource, +): Promise => { + if (source.sourceKind !== "file") return undefined; + let reason: string; + let cause: unknown; + try { + if (!(await stat(source.path)).isFile()) + reason = "it is not a regular file"; + else { + await access(source.path, constants.R_OK); + return undefined; + } + } catch (failure: unknown) { + cause = failure; + const code = + failure instanceof Error && "code" in failure ? failure.code : undefined; + if (code === "EACCES" || code === "EPERM") + return new AnalysisAccessDeniedError( + "instrument_with_frida", + source.path, + code, + { cause }, + ); + reason = + code === "ENOENT" ? "no file exists there" : String(code ?? failure); + } + return new AnalysisInputError( + "instrument_with_frida", + cause === undefined ? undefined : { cause }, + [ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot read Frida script file ${source.path}: ${reason}.`, + }, + ], + ); +}; + +/** + * Reject an unreadable script file before any attach or spawn effect; + * cancellation before or during the check still wins. + */ +const rejectBeforeStart = async ( + source: FridaScriptSource, + signal: AbortSignal | undefined, +): Promise => { + const cancelled = () => + new AnalysisCancelledError( + "instrument_with_frida", + signal?.reason === undefined ? {} : { cause: signal.reason }, + ); + if (signal?.aborted) return cancelled(); + const unreadable = await unreadableScriptFile(source); + return signal?.aborted ? cancelled() : unreadable; +}; + /** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */ export class FridaInstrumentationService { readonly #provider: FridaInstrumentationPort; @@ -163,6 +225,8 @@ export class FridaInstrumentationService { AnalysisError > > { + const rejected = await rejectBeforeStart(input.source, signal); + if (rejected !== undefined) return err(rejected); const started = await this.#startSession( input, signal, From 7711b0000592aa80c7b0412004150313f78feabc Mon Sep 17 00:00:00 2001 From: nakshatra k <95270855+Zburgers@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:42:10 +0530 Subject: [PATCH 08/93] fix(apple): preflight XML plist nesting before keyed archive decoding (#2161) * fix(apple): preflight XML plist nesting before keyed archive decoding inspect_keyed_archive documented a 128-level nesting limit, but the XML plist path reached recursive decoding before any depth check: plist and the prototype-key traversal in parseXmlPropertyList recurse once per nesting level, so a ~75 KiB, 3000-level XML plist exhausted the call stack and reported Maximum call stack size exceeded instead of the bounded-resource limit that shallower over-limit documents (129, 200) already produced. The CLI classified both as truncated, so no crash or classification bypass was involved; the diagnostic was the defect. Reject excessive nesting in parseXmlPropertyList before any recursive pass, using the projection's own limit (MAX_PLIST_DEPTH) so the error is identical to the one projectPlistValue raises: callers keep the limit/truncated classification with no error-handler translation. Depth counts values the way the projection does - container members nest one level deeper, dictionary keys name members rather than nesting, and a self-closing element is the value at its own level - so the guard rejects exactly the documents the projection rejected, at the same boundary, and every in-limit document decodes unchanged. The guard sits at the shared decoder boundary, so Interface Builder, app Info.plist resolution and native inspect_plist gain the same deterministic limit; the binary plist path keeps its existing expansion preflight. Binary plist behavior, ordinary XML decode, __proto__ omission counting, and archive digest/Evidence provenance are unchanged. OpenStep autodetection is untouched by this guard, as the existing Interface Builder preflight also scopes its depth check to XML. Regression coverage: decoder unit tests at and just past the limit for dictionary and array nesting with self-closing and opaque markup, module tests through decodeKeyedArchiveBytes including a valid NSKeyedArchiver XML envelope at the limit, a production-boundary test through inspectBundleKeyedArchive, and the compiled public CLI reporting code truncated / reason limit for a 3000-level plist. * fix(apple): check XML nesting against parsed element structure Reuse the iterative XML decoder document for depth preflight and prototype-key lookup. Bound recursive plist wrappers alongside dictionaries and arrays without interpreting markup inside attributes or DOCTYPE declarations as values. Validated with 104 focused tests including compiled CLI, MCP prototype-key, and Interface Builder coverage; npm run check and verify:test-discovery passed. No real-provider verification was run. --------- Co-authored-by: Zburgers Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../apple/KeyedArchiveReader.test.ts | 66 +++++++++- src/domain/apple/plistValue.ts | 7 +- src/domain/propertyListKeys.test.ts | 113 ++++++++++++++++++ src/domain/propertyListKeys.ts | 67 +++++++++-- .../cli/keyedArchiveXmlNestingCli.test.ts | 78 ++++++++++++ .../filesystem/keyedArchiveXmlNesting.test.ts | 29 +++++ tests/fixtures/keyedArchiveXmlNesting.ts | 9 ++ 7 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts create mode 100644 tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts create mode 100644 tests/fixtures/keyedArchiveXmlNesting.ts diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 2268858b55..027bbcd3f0 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { build, buildBinary } from "plist"; +import { build, buildBinary, type PlistValue } from "plist"; import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.js"; +import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js"; const archive = { $archiver: "NSKeyedArchiver", @@ -323,3 +324,66 @@ describe("binary plist reference preflight", () => { ); }); }); + +describe("XML plist nesting preflight", () => { + const selection = { offset: 0, limit: 1 }; + + /** A valid XML NSKeyedArchiver envelope whose object 1 nests `depth` dictionaries. */ + const deeplyNestedArchive = (depth: number) => { + let payload: PlistValue = "leaf"; + for (let level = 0; level < depth; level += 1) payload = { k: payload }; + return Buffer.from( + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", payload], + $top: { root: { UID: 1 } }, + }), + ); + }; + + /** Follow the single-member chain of a decoded object to its deepest value. */ + const deepestValue = (value: unknown): unknown => { + let cursor = value; + while ( + cursor !== null && + typeof cursor === "object" && + !Array.isArray(cursor) && + "k" in cursor + ) { + cursor = (cursor as Record).k; + } + return cursor; + }; + + it.each([129, 200, 3000, 4000])( + "rejects %i-level XML nesting as the documented resource limit", + (depth) => { + // Recursive decoding exceeds the call stack before the projection limit + // can report it, so the decoder must reject the nesting up front with + // the same bounded-resource error an in-reach document would produce. + expect(() => + decodeKeyedArchiveBytes( + Buffer.from(deeplyNestedXmlPlist(depth)), + selection, + ), + ).toThrow(new RangeError("Plist exceeds 128 nesting levels")); + }, + ); + + it("decodes a valid XML keyed archive exactly at the nesting limit", () => { + const graph = decodeKeyedArchiveBytes(deeplyNestedArchive(126), { + root: "root", + offset: 1, + limit: 1, + }); + expect(graph.objects).toHaveLength(1); + expect(deepestValue(graph.objects[0]?.value)).toBe("leaf"); + }); + + it("rejects one nesting level past the limit before decoding", () => { + expect(() => + decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection), + ).toThrow(new RangeError("Plist exceeds 128 nesting levels")); + }); +}); diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index dc6e4d0ea4..418688dd72 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -2,7 +2,12 @@ import { z } from "zod"; import type { JsonValue } from "../jsonValue.js"; -const MAX_PLIST_DEPTH = 128; +/** + * Maximum plist value nesting the JSON projection supports. The XML decode + * preflight shares this limit so oversized documents are rejected before any + * recursive pass rather than during one. + */ +export const MAX_PLIST_DEPTH = 128; /** A decoded plist projected into JSON, with values JSON cannot express. */ export interface ProjectedPlistValue { diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index c6c157a014..ded36daff8 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -98,6 +98,119 @@ it("preserves OpenStep dictionary and array autodetection", () => { }); }); +/** The single-member chain of a nested dictionary, down to its deepest value. */ +const deepestValue = (value: unknown): unknown => { + let cursor = value; + while ( + cursor !== null && + typeof cursor === "object" && + !Array.isArray(cursor) && + "k" in cursor + ) { + cursor = (cursor as Record).k; + } + return cursor; +}; + +/** The single-member chain of a nested array, down to its deepest value. */ +const deepestEntry = (value: unknown): unknown => { + let cursor = value; + while (Array.isArray(cursor) && cursor.length === 1) cursor = cursor[0]; + return cursor; +}; + +const nestedDictionaries = (depth: number) => + plist( + "k".repeat(depth) + + "x" + + "".repeat(depth), + ); + +const nestedArrays = (depth: number) => + plist( + "".repeat(depth) + "x" + "".repeat(depth), + ); + +it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => { + // `plist` and the prototype-key traversal recurse once per level, so the + // depth check must report the same bounded-resource error as the projection + // instead of letting those recursive passes exhaust the call stack. + const limit = new RangeError("Plist exceeds 128 nesting levels"); + expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit); +}); + +it("decodes XML property lists exactly at the projection nesting limit", () => { + expect( + deepestValue(parseXmlPropertyList(nestedDictionaries(128)).value), + ).toBe("x"); + expect(deepestEntry(parseXmlPropertyList(nestedArrays(128)).value)).toBe("x"); + expect( + parseXmlPropertyList( + plist("".repeat(128) + "" + "".repeat(128)), + ).value, + ).toBeDefined(); +}); + +it("does not count markup inside comments, CDATA, or the doctype as nesting", () => { + const text = ` +">]> + + a]]> + + bx +`; + expect(parseXmlPropertyList(text)).toEqual({ + value: { a: "", b: ["x"] }, + omittedPrototypeKeys: 0, + }); +}); + +it.each(['"', "'"])( + "counts containers with quoted XML delimiters using %s attributes", + (quote) => { + const opening = `${quote}>k`; + const text = plist( + opening.repeat(3000) + "x" + "".repeat(3000), + ); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList( + plist( + "" + `${quote}/>`.repeat(130) + "", + ), + ).value, + ).toEqual(Array.from({ length: 130 }, () => ({}))); + }, +); + +it("ignores complete DOCTYPE internal subsets when checking value depth", () => { + const text = ` + +".repeat(130)}"> +]>x`; + expect(parseXmlPropertyList(text)).toEqual({ + value: "x", + omittedPrototypeKeys: 0, + }); +}); + +it("bounds nested plist wrappers before recursive decoding", () => { + const text = + "".repeat(3000) + "x" + "".repeat(3000); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList(plist("x")).value, + ).toEqual(["x"]); +}); + it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])( "preserves unrelated source values around %j line endings", (newline) => { diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 778af9295e..35aa0889d9 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -1,6 +1,13 @@ -import { DOMParser, normalizeLineEndings } from "@xmldom/xmldom"; +import { + DOMParser, + normalizeLineEndings, + type Document, + type Node, +} from "@xmldom/xmldom"; import { parse } from "plist"; +import { MAX_PLIST_DEPTH } from "./apple/plistValue.js"; + /** The dictionary key REA results cannot carry: validation strips it. */ const PROTOTYPE_KEY = "__proto__"; @@ -88,18 +95,64 @@ export const parseXmlPropertyList = ( // by app Info.plist resolution. Only XML needs the key-element substitution. const first = text.trimStart()[0]; if (first === "{" || first === "(") return omitPrototypeKeys(parse(text)); + // Normalize only the literal line endings the pinned decoder normalizes. + // Preserve character references in the source: serializing a DOM would turn + // e.g. an encoded carriage return into a literal newline on the second parse. + const normalized = normalizeLineEndings(text); + // The pinned XML parser is iterative. Reuse its document for the depth + // preflight and key lookup so quoted attributes and declarations have the + // same meaning at both boundaries, before plist's recursive value decode. + const document = new DOMParser().parseFromString(normalized, "text/xml"); + assertXmlPlistDepthWithinLimit(document); const { substituted, placeholder } = substitutePrototypeKeys( - normalizeLineEndings(text), + normalized, + document, ); return withoutKey(parse(substituted), placeholder); }; +/** Elements whose children the pinned plist value decoder visits recursively. */ +const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]); + +/** + * Reject XML plists nested deeper than the projection supports before any + * recursive value pass runs. `plist` and the prototype-key traversal recurse once + * per nesting level, so a deeply nested document exhausts the call stack + * before the projection limit can report the resource bound. The failure + * matches `projectPlistValue` so callers classify it as a limit failure. + * + * Depth counts values the way the projection does: the root value is level 0 + * and every container member is one level deeper. A dictionary key names a + * member instead of nesting one, and a self-closing element is the value at + * its own level. The outer plist envelope is unwrapped by the decoder; + * nested plist elements decode as arrays and must count toward the bound. + */ +const assertXmlPlistDepthWithinLimit = (document: Document): void => { + const root = document.documentElement; + if (root === null) return; + const pending: Array<{ node: Node; depth: number }> = [ + { node: root, depth: -1 }, + ]; + while (pending.length > 0) { + const entry = pending.pop(); + if (entry === undefined) break; + const { node, depth } = entry; + // Schedule only the next sibling, keeping traversal storage proportional + // to depth rather than the number of members in a wide container. + if (node.nextSibling !== null) + pending.push({ node: node.nextSibling, depth }); + if (node.nodeType !== node.ELEMENT_NODE) continue; + if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH) + throw new RangeError( + `Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`, + ); + if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null) + pending.push({ node: node.firstChild, depth: depth + 1 }); + } +}; + /** Locate keys with the same XML parser and first-child semantics as plist. */ -const substitutePrototypeKeys = (text: string) => { - // Normalize only the literal line endings the pinned decoder normalizes. - // Preserve character references in the source: serializing a DOM would turn - // e.g. an encoded carriage return into a literal newline on the second parse. - const document = new DOMParser().parseFromString(text, "text/xml"); +const substitutePrototypeKeys = (text: string, document: Document) => { const keys = Array.from(document.getElementsByTagName("key")); const decodedKeys = new Set(keys.map((key) => key.firstChild?.nodeValue)); let placeholder = "__rea_prototype_key__"; diff --git a/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts new file mode 100644 index 0000000000..6e37acd239 --- /dev/null +++ b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { build } from "plist"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const [name, xml] of [ + ["dictionaries", deeplyNestedXmlPlist(3000)], + [ + "quoted attributes", + deeplyNestedXmlPlist(3000).replaceAll("", ''), + ], + [ + "plist wrappers", + "".repeat(3000) + "x" + "".repeat(3000), + ], +] as const) { + cliTest( + `compiled CLI reports deeply nested XML ${name} as the decode limit`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-cli-depth-"); + const path = join(root, "Deep.plist"); + await writeFile(path, xml); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(1); + expect(output.json).toMatchObject({ + code: "truncated", + category: "truncated", + details: { + reason: "limit", + truncated: true, + detail: + "Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels", + }, + }); + }, + ); +} + +cliTest( + "compiled CLI decodes a shallow archive with opaque DOCTYPE markup", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-cli-doctype-"); + const path = join(root, "Shallow.plist"); + const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { UID: 1 } }, + }); + await writeFile( + path, + archive.replace( + /]*>/u, + `".repeat(130)}">]>`, + ), + ); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + expect(parseEvidence(output.json).normalized_result).toMatchObject({ + archive_format: "xml-plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "leaf" }), + ]), + }); + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts new file mode 100644 index 0000000000..e2dbe084dc --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -0,0 +1,29 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; + +it.each([129, 3000])( + "reports XML nesting of %i levels as the keyed archive decode limit", + async (depth) => { + const root = await createTestTempDirectory("rea-keyed-depth-"); + await writeFile(join(root, "Deep.plist"), deeplyNestedXmlPlist(depth)); + const rejected = await inspectBundleKeyedArchive({ + bundlePath: root, + targetSha256: "a".repeat(64), + parameters: { path: "Deep.plist" }, + platform: "linux", + }).catch((cause: unknown) => cause); + // Recursive decoding exhausts the call stack before the projection limit + // can report it, so the limit diagnostic must come from the preflight. + expect(rejected).toMatchObject({ + reason: "limit", + message: + "Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels", + }); + }, +); diff --git a/tests/fixtures/keyedArchiveXmlNesting.ts b/tests/fixtures/keyedArchiveXmlNesting.ts new file mode 100644 index 0000000000..77a1b3bc29 --- /dev/null +++ b/tests/fixtures/keyedArchiveXmlNesting.ts @@ -0,0 +1,9 @@ +/** A well-formed XML plist of N nested dictionaries, each with one key. */ +export const deeplyNestedXmlPlist = (depth: number): string => + '\n' + + '\n' + + '\n' + + "k".repeat(depth) + + "x" + + "".repeat(depth) + + "\n\n"; From b1bf4a70d44191348ca20a91a52fb663da4daed0 Mon Sep 17 00:00:00 2001 From: BeqaKs <91750923+BeqaKs@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:24:24 +0400 Subject: [PATCH 09/93] fix(setup): name invalid client config shape errors clearly (#2157) * fix(setup): name invalid client config shape errors clearly Setup was dumping raw Zod issues for non-object roots or server tables, and uninstall always claimed the file was not valid JSON. Report the structural problem instead, and reserve parse failures for real syntax errors. Fixes #2152 Co-authored-by: Cursor * fix(setup): preserve client configuration record validation Keep descriptive shape errors while retaining Zod record validation and its own-property copy. Reject tagged YAML objects before setup or uninstall writes and prevent inherited JSONC properties from fabricating client registrations. Add production-boundary regressions for tagged YAML roots and server tables, plus inherited Cursor and OpenCode settings. All 91 focused tests, npm run check, verify:test-discovery, verify:package, and npm pack --dry-run passed. --------- Co-authored-by: Beqa Ksovreli Co-authored-by: Cursor Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../ClientConfigurationDocument.shape.test.ts | 34 +++++++ .../ClientConfigurationDocument.ts | 26 +++--- .../filesystem/clientLifecycle.test.ts | 5 +- .../hermesClientConfiguration.test.ts | 89 +++++++++++++++--- tests/boundary/filesystem/setupConfig.test.ts | 90 ++++++++++++++++++- 5 files changed, 213 insertions(+), 31 deletions(-) create mode 100644 src/application/ClientConfigurationDocument.shape.test.ts diff --git a/src/application/ClientConfigurationDocument.shape.test.ts b/src/application/ClientConfigurationDocument.shape.test.ts new file mode 100644 index 0000000000..67c557a440 --- /dev/null +++ b/src/application/ClientConfigurationDocument.shape.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { parseClientConfiguration } from "./ClientConfigurationDocument.js"; + +describe("parseClientConfiguration shape diagnostics", () => { + it.each([ + ["null", "null"], + ["array", "[]"], + ["string", '"x"'], + ] as const)("names a non-object JSON root (%s)", (_label, text) => { + expect(() => parseClientConfiguration(text, "json")).toThrow( + new TypeError("The configuration root must be an object"), + ); + }); + + it.each([ + ["null", "null"], + ["array", "[]"], + ["string", '"x"'], + ] as const)( + 'names a non-object "mcpServers" value (%s)', + (_label, servers) => { + expect(() => + parseClientConfiguration(`{"mcpServers":${servers},"keep":1}`, "json"), + ).toThrow(new TypeError('"mcpServers" must be an object')); + }, + ); + + it("still rejects unparsable JSON with SyntaxError", () => { + expect(() => parseClientConfiguration("not-json", "json")).toThrow( + SyntaxError, + ); + }); +}); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index cecbd440ab..b32df3fd01 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -107,18 +107,21 @@ const parseDocument = ( format: ClientConfigurationFormat, ): Record => { if (format === "toml" || format === "grok") - return objectSchema.parse(parseToml(text)); + return requireObject(parseToml(text), "The configuration root"); // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be // rejected instead of registering into a document Pi cannot load. - if (format === "pi") return objectSchema.parse(JSON.parse(text)); + if (format === "pi") + return requireObject(JSON.parse(text), "The configuration root"); // An empty file, which some clients create before any server is added, // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. - if (format === "hermes") return objectSchema.parse(parseYaml(text)); + if (format === "hermes") + return requireObject(parseYaml(text), "The configuration root"); // ZCode loads nonempty native files with strict JSON.parse. - if (format === "zcode") return objectSchema.parse(JSON.parse(text)); + if (format === "zcode") + return requireObject(JSON.parse(text), "The configuration root"); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; @@ -128,12 +131,13 @@ const parseDocument = ( throw new SyntaxError( `Invalid JSON/JSONC at offset ${firstError.offset}: ${printParseErrorCode(firstError.error)}`, ); - return objectSchema.parse(document); + return requireObject(document, "The configuration root"); }; /** A parsed configuration whose structure, not its syntax, cannot hold MCP servers. */ export class ClientConfigurationShapeError extends TypeError {} +/** Validate and copy own properties, excluding non-record YAML values and parser-injected prototypes. */ const requireObject = ( value: unknown, description: string, @@ -174,17 +178,7 @@ export const parseClientConfiguration = ( throw new TypeError("client does not have a supported MCP config format"); // DeepSeek Harness stores a Cordis patch list, not a server table. if (format === "dsh") return parseDshCordisDocument(text); - let document: Record; - try { - document = parseDocument(text, format); - } catch (cause: unknown) { - if (cause instanceof z.ZodError) - throw new ClientConfigurationShapeError( - "The configuration root must be an object", - { cause }, - ); - throw cause; - } + const document = parseDocument(text, format); const serversKey = clientConfigurationServersKey(format); const value = document[serversKey]; const servers = diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 2d814a5f36..506a6d6dfa 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -1661,8 +1661,9 @@ describe("uninstall configuration preflight", () => { }); it.each([ - ['{"mcpServers": null}', '"mcpServers" must be an object'], + ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], ["[]", "The configuration root must be an object"], + ['{"mcpServers":"x"}', '"mcpServers" must be an object'], ])( "names the structural problem in valid JSON %s", async (content, problem) => { @@ -1679,12 +1680,14 @@ describe("uninstall configuration preflight", () => { }, }); const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("failed"); expect(result.items).toContainEqual({ name: "cursor", status: "failed", detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, }); expect(await readFile(config, "utf8")).toBe(content); + await expect(readFile(`${config}.rea.backup`, "utf8")).rejects.toThrow(); }, ); diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index f6b3815ee9..43a5f841c0 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -6,9 +6,15 @@ import { parse as parseYaml } from "yaml"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; -import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; -import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { + runUninstall, + systemUninstallHost, +} from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; @@ -90,18 +96,75 @@ describe("Hermes configuration paths", () => { }); }); -describe("Hermes YAML registration", () => { - const hermesClient = async () => { - const home = await createTestTempDirectory("rea-hermes-"); - const client = supportedClients(home, process.platform, {}).find( - ({ name }) => name === "hermes", - ); - if (client?.markerPath === undefined) - throw new Error("missing Hermes client"); - await mkdir(client.markerPath, { recursive: true }); - return { home, client }; - }; +const hermesClient = async () => { + const home = await createTestTempDirectory("rea-hermes-"); + const client = supportedClients(home, process.platform, {}).find( + ({ name }) => name === "hermes", + ); + if (client?.markerPath === undefined) + throw new Error("missing Hermes client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; +}; +describe("Hermes YAML shape diagnostics", () => { + it.each( + [ + "!!set {rea: null}", + "!!omap [{rea: {command: rea, args: [mcp]}}]", + "!!timestamp 2001-12-15", + "!!binary cmVh", + ].flatMap((value) => [ + { + original: `${value}\n`, + problem: "The configuration root must be an object", + }, + { + original: `mcp_servers: ${value}\n`, + problem: '"mcp_servers" must be an object', + }, + ]), + )( + "blocks setup and uninstall before changes for tagged non-record YAML: $original", + async ({ original, problem }) => { + const { home, client } = await hermesClient(); + const cursor = join(home, ".cursor", "mcp.json"); + const cursorOriginal = + '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}'; + await mkdir(join(home, ".cursor")); + await writeFile(cursor, cursorOriginal); + await writeFile(client.configPath, original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "invalid", + remediation: `Configuration ${client.configPath} is malformed: ${problem}`, + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, {}), + ); + expect(result.status).toBe("failed"); + expect(result.items).toContainEqual({ + name: "hermes", + status: "failed", + detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await readFile(cursor, "utf8")).toBe(cursorOriginal); + await expect( + readFile(`${client.configPath}.rea.backup`, "utf8"), + ).rejects.toThrow(); + await expect(readFile(`${cursor}.rea.backup`, "utf8")).rejects.toThrow(); + }, + ); +}); + +describe("Hermes YAML registration", () => { it("writes mcp_servers, preserves comments, reads back, and uninstalls", async () => { const { home, client } = await hermesClient(); await writeFile( diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 6819b69cd7..79b489de5c 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js"; -import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; @@ -330,3 +334,87 @@ describe("JSON client configuration migration and provider paths", () => { ).rejects.toThrow(); }); }); + +describe("JSON client configuration shape diagnostics", () => { + it.each([ + { + format: "json" as const, + original: + '{"__proto__":{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}}', + serversKey: "mcpServers", + }, + { + format: "opencode" as const, + original: + '{"mcp":{"__proto__":{"servers":{"rea":{"type":"local","command":["rea","mcp"]}}}}}', + serversKey: "mcp", + }, + ])( + "registers an own server instead of trusting inherited $format settings", + async ({ format, original, serversKey }) => { + const directory = await createTestTempDirectory("rea-setup-prototype-"); + const configPath = join(directory, "mcp.json"); + const client = { + name: format === "json" ? "cursor" : "opencode", + configPath, + format, + }; + await writeFile(configPath, original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "update" }); + expect( + await configureClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "configured" }); + const document = JSON.parse(await readFile(configPath, "utf8")); + expect(Object.hasOwn(document, serversKey)).toBe(true); + expect(Object.hasOwn(document[serversKey], "rea")).toBe(true); + expect(document[serversKey].rea.command).toEqual( + format === "json" ? "rea" : ["rea", "mcp"], + ); + const originalDocument = JSON.parse(original); + expect( + format === "json" ? document.__proto__ : document[serversKey].__proto__, + ).toEqual( + format === "json" + ? originalDocument.__proto__ + : originalDocument[serversKey].__proto__, + ); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "already_current" }); + }, + ); + + it.each([ + ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], + ["[]", "The configuration root must be an object"], + ['{"mcpServers":"x"}', '"mcpServers" must be an object'], + ] as const)( + "names structural shape problems in setup remediation: %s", + async (original, problem) => { + const directory = await createTestTempDirectory("rea-setup-shape-"); + const configPath = join(directory, "mcp.json"); + const client = { + name: "cursor", + configPath, + format: "json" as const, + }; + await writeFile(configPath, original); + const read = await readConfigurationFile(configPath, "json"); + expect(read.ok).toBe(false); + if (read.ok) throw new Error("expected malformed configuration"); + expect(read.error.detail).toBe( + `Configuration ${configPath} is malformed: ${problem}`, + ); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toEqual({ + status: "invalid", + remediation: `Configuration ${configPath} is malformed: ${problem}`, + }); + expect(await readFile(configPath, "utf8")).toBe(original); + }, + ); +}); From ac6ef95e12335bdc054ccaaa96c32474cf8789ea Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:36:56 +0800 Subject: [PATCH 10/93] docs: define contribution scope and simplify proposal forms Define REA contribution scope and fail closed on unresolved integration boundaries. Make proposal forms easier to complete with structured choices, explicit unknowns, and optional technical details; welcome exploratory drafts while retaining maintainer review before integration. --- .github/ISSUE_TEMPLATE/config.yml | 3 + .github/ISSUE_TEMPLATE/design_discussion.yml | 68 +++++++++++--- .github/ISSUE_TEMPLATE/feature_request.yml | 75 +++++++++++++--- .github/pull_request_template.md | 32 ++++++- AGENTS.md | 2 + CONTRIBUTING.md | 95 ++++++++++++++++++++ 6 files changed, 248 insertions(+), 27 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 02c8ed478e..e45d5d566f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Contribution scope and integration requirements + url: https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope + about: Check supported capabilities, excluded workflows, and the fail-closed integration rule before proposing a feature. - name: REA Documentation url: https://github.com/morluto/rea#readme about: Read the README and AGENTS.md for usage guides and configuration details. diff --git a/.github/ISSUE_TEMPLATE/design_discussion.yml b/.github/ISSUE_TEMPLATE/design_discussion.yml index 912e18c44a..38d33ac821 100644 --- a/.github/ISSUE_TEMPLATE/design_discussion.yml +++ b/.github/ISSUE_TEMPLATE/design_discussion.yml @@ -1,5 +1,5 @@ name: Design discussion -description: Resolve an REA architecture, provider, or contract question before implementation +description: Discuss an REA architecture, provider, contract, or scope decision title: "[Design] " labels: - enhancement @@ -9,7 +9,12 @@ body: attributes: value: | Use this for a substantial provider, public/persisted contract, or - authority-boundary decision. Keep one decision per discussion. + authority-boundary decision. One question and a short explanation are + enough to start. Choose "Not sure yet" where needed; maintainers can help + with technical details, alternatives, and verification. + + Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). + Unresolved boundaries block integration, not opening a discussion. - type: checkboxes id: before-submitting @@ -18,14 +23,16 @@ body: options: - label: I searched the open and closed issues and pull requests for a related discussion. required: true - - label: I removed credentials, tokens, private paths, and sensitive target data. + - label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. + required: true + - label: This discussion does not include credentials, private samples, or sensitive target data. required: true - type: textarea id: decision attributes: label: Decision needed - description: State the exact question maintainers need to resolve. + description: What would you like maintainers to help decide? A short question is enough. placeholder: Should REA ... or ...? validations: required: true @@ -34,23 +41,57 @@ body: id: context-evidence attributes: label: Context and evidence - description: Explain why the decision is needed now. Separate observed behavior, derived evidence, and inference. + description: Explain the problem or motivation. If you have evidence, distinguish observed behavior from ideas or guesses. + validations: + required: true + + - type: dropdown + id: target-interaction + attributes: + label: Expected interaction with targets + description: >- + Select all known effects, including upstream tool behavior. Add "Not sure + yet" if anything is unclear. Use "No target interaction" only on its own. + These selections help discussion and do not establish scope approval. + multiple: true + options: + - Read existing files or captured data + - Launch or attach to a process + - Inject instrumentation or run scripts/code + - Change process memory or registers + - Write or change files/configuration + - Contact network services + - Recover protected code or work around protections + - No target interaction (documentation or usability only) + - Not sure yet validations: required: true - type: textarea id: boundary-invariants attributes: - label: Boundary and invariants - description: Identify the owning layer, provider/bridge boundary, artifact or evidence invariants, and any process or authorization constraints. - validations: - required: true + label: Boundary details (optional) + description: >- + If known, clarify target selection, selected effects, provider boundaries, + non-goals, or evidence invariants. Leave blank if you need help identifying + them. Do not include private samples or proof-of-authorization documents. - type: textarea id: options attributes: label: Options and tradeoffs - description: Compare a small number of viable approaches, including platform, compatibility, safety, evidence, and maintenance costs. + description: Optional approaches or tradeoffs. One idea is enough; maintainers can help compare alternatives. + + - type: dropdown + id: verification-readiness + attributes: + label: Verification available so far + description: Pick the closest match. An idea without samples or test results is welcome. + options: + - Idea only; I need help defining checks + - I can suggest a sample or manual check + - I have a prototype or observed results + - Not sure yet validations: required: true @@ -58,9 +99,10 @@ body: id: success-criteria attributes: label: Success criteria and open questions - description: State what outcome would settle the decision and list only unresolved questions that could change the choice. - validations: - required: true + description: >- + Optional expected output, sample, possible check, or unresolved question. + A complete test plan is not required to start; maintainers and implementers + will resolve material verification gaps before integration. - type: textarea id: references diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7b0767b276..4125f3db52 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,9 +8,13 @@ body: - type: markdown attributes: value: | - Keep one primary capability or workflow outcome per request. For a - substantial provider, public contract, or architecture change, explain - the decision maintainers need to make before implementation. + Describe one problem and the outcome you would like. Short answers are + welcome. Choose "Not sure yet" wherever needed; you do not need code, a + finished design, or a complete test plan to propose an idea. + + Read the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). + Maintainers can help resolve scope and technical questions. Unresolved + boundaries block integration, not opening a request for discussion. - type: checkboxes id: before-submitting @@ -19,7 +23,9 @@ body: options: - label: I searched the open and closed issues and pull requests for a related proposal. required: true - - label: I removed credentials, tokens, private paths, and sensitive target data. + - label: I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. + required: true + - label: This request does not include credentials, private samples, or sensitive target data. required: true - type: dropdown @@ -36,6 +42,7 @@ body: - MCP or agent integration - Documentation - Other + - Not sure yet validations: required: true @@ -43,7 +50,7 @@ body: id: current-limitation attributes: label: Current limitation - description: What user or agent workflow is blocked, ambiguous, or unnecessarily difficult today? + description: What are you trying to understand or do, and what is difficult today? A short example is enough. validations: required: true @@ -51,23 +58,69 @@ body: id: use-cases attributes: label: Use cases - description: Give concrete applications, targets, callers, or investigation workflows. Explain why current tools are insufficient. - validations: - required: true + description: Optional additional examples of applications, targets, or callers. Leave blank if covered above. - type: textarea id: desired-outcome attributes: label: Desired outcome and success criteria - description: Describe observable behavior, inputs, outputs, evidence, and compatibility criteria that would make this useful. + description: What would a useful result look like? Describe the output or behavior you want; implementation details are optional. + placeholder: For a selected APK, show whether it is packed and include the detection evidence. + validations: + required: true + + - type: dropdown + id: target-interaction + attributes: + label: Expected interaction with targets + description: >- + Select all known effects, including upstream tool behavior. Add "Not sure + yet" if anything is unclear. Use "No target interaction" only on its own. + These selections help discussion and do not establish scope approval. + multiple: true + options: + - Read existing files or captured data + - Launch or attach to a process + - Inject instrumentation or run scripts/code + - Change process memory or registers + - Write or change files/configuration + - Contact network services + - Recover protected code or work around protections + - No target interaction (documentation or usability only) + - Not sure yet validations: required: true - type: textarea id: boundaries attributes: - label: Authority boundaries and non-goals - description: Call out process execution, target mutation, provider licensing, evidence provenance, supported platforms, and work that should remain out of scope. + label: Target and scope details (optional) + description: >- + If known, name the target/platform, clarify selected effects, or list a + non-goal or scope question. You can leave this blank; maintainers can help. + Do not include private samples or proof-of-authorization documents. + + - type: dropdown + id: verification-readiness + attributes: + label: Verification available so far + description: Pick the closest match. An idea without samples or test results is welcome. + options: + - Idea only; I need help defining checks + - I can suggest a sample or manual check + - I have a prototype or observed results + - Not sure yet + validations: + required: true + + - type: textarea + id: verification + attributes: + label: Verification details (optional) + description: >- + Share an expected output, a possible check, or existing results if you have + them. Self-owned or appropriately licensed samples can help, but are not + required to open a request. Do not attach private or sensitive data. - type: textarea id: alternatives diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e33ce01f4f..3dec52bd07 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,17 +19,39 @@ describe the use case and observable outcome. --> +Follow the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope). +Unresolved scope, authority, side effects, or required verification block +integration. Link the required maintainer scope decision for new providers or +capabilities involving target execution, injection, mutation, or protection +recovery; ordinary fixes within existing boundaries may state "not applicable". +Draft PRs may leave the scope decision pending and the final review checklist +unchecked. Maintainers can help resolve open questions. + ## Contract and boundary impact - + - Semantic owner and earliest changed stage: +- Contribution scope fit and linked maintainer decision: - CLI and MCP/tool-catalog contract: - Provider, bridge, target-format, or platform compatibility: - Evidence, artifact, provenance, or reconstruction contract: -- Process execution, authorization, cleanup, or containment impact: +- Target selection, authority, upstream behavior, and cleanup details (if applicable): - Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `.agents/skills/reverse-engineer-anything/` and never commit build outputs): +Expected effects (check known effects; use documentation-only on its own). +Unchecked items do not establish absence; leave uncertainties visible. + +- [ ] Read existing files or captured data. +- [ ] Launch or attach to a process. +- [ ] Inject instrumentation or run scripts/code. +- [ ] Change process memory, registers, files, or configuration. +- [ ] Contact network services. +- [ ] Recover protected code or work around protections. +- [ ] Documentation or usability only; no target interaction. +- [ ] Not sure yet; investigation or maintainer guidance needed. + ## Evidence and regression coverage - [ ] This PR addresses a concrete problem or an agreed enhancement. +- [ ] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. - [ ] The PR has one focused outcome and the title follows `type(scope): outcome`. - [ ] Related issue is linked, or the reason for not linking one is stated above. - [ ] Tests cover changed observable behavior and meaningful failure paths. diff --git a/AGENTS.md b/AGENTS.md index 6ee0229f2b..9fb27e7659 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ REA is a local-only reverse-engineering tool with shared CLI/MCP workflows. Distinguish observations, derivations, inferences, and unknowns; preserve Evidence provenance and session semantics through both adapters. Provider support and setup live in [README.md](README.md#choosing-a-deep-analysis-provider) and its linked guides. +Follow the [contribution scope and fail-closed integration rule](CONTRIBUTING.md#contribution-scope). Do not integrate capabilities while material scope, authority, side-effect, or required verification questions remain unresolved. For new providers or capabilities involving target execution, injection, mutation, or protection recovery, seek the required maintainer scope decision before substantial implementation for integration. Welcome early proposals, small exploratory prototypes, and draft PRs that help resolve questions; they do not authorize integration. + Ghidra uses ephemeral databases without modifying executable bytes or controlling a GUI; Windows Ghidra P0 has no mutation authority. IDA analysis is read-only; never save or close an attached GUI database. Configuration changes must be additive and idempotent, with backups. Installers must not install or upgrade unrelated software, including Homebrew, Node.js, npm, Java, or Ghidra. Ghidra and IDA are bring-your-own. `rea setup` must show its planned changes and require approval before writing files or installing Hopper. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7a65be588..1dc0dd4204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,101 @@ boundaries are checked by `npm run verify:module-boundaries`, included in lint. When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog. +## Contribution scope + +REA focuses on software structure, runtime behavior, and evidence through +general-purpose reverse-engineering workflows. Evaluate a proposal by its actual +capabilities, side effects, defaults, and complete workflow. A tool name or a +reference to vulnerability research, malware analysis, or CTFs does not determine +whether it belongs in REA. + +Supported contributions include format inspection, ordinary archive and standard +compression extraction, disassembly, decompilation, cross-references, general +static deobfuscation, vulnerability analysis, and evidence reporting. Static +protection detection is distinct from defeating that protection. Security fixes +and isolated regression tests remain in scope. + +Runtime investigation needs explicit boundaries. Launching or attaching to a +process, injecting instrumentation, running scripts, and changing memory or +registers require review of target selection, execution and mutation authority, +provenance, resource limits, cancellation, and cleanup. General Frida or debugger +support is not inherently read-only, and existing support does not authorize +every workflow built on it. General-purpose interfaces are not classified as +dedicated bypass workflows solely because caller-provided code could be misused; +their execution and mutation authority must still be reviewed. + +REA does not integrate or officially distribute: + +- Purpose-built workflows to defeat commercial packing or code-virtualization + protections and automatically recover protected code. +- Anti-debugging or anti-instrumentation evasion, or DRM, license, anti-cheat, or + access-control bypass workflows. +- Exploit or attack-payload automation, credential theft, covert persistence, + unauthorized access, or bulk attack workflows. + +These boundaries apply to first-party providers, adapters, plugins, bundled +scripts, defaults, examples, and documentation. Moving a capability into an +optional dependency or plugin does not exempt it. Review what the integration +exposes, including upstream defaults and raw-command or script execution paths. +Ordinary extraction and general static deobfuscation are not excluded merely +because the input is packed or obfuscated; assess added bypass or execution +behavior separately. + +This is a product and maintenance boundary, not a judgment about a contributor's +intentions or a declaration that all excluded research is unlawful. Contributors +must have the rights and authorization needed for their work. A local-only design +or an "authorized use only" disclaimer does not establish containment or settle +legal and scope questions. + +### Fail-closed integration + +If scope fit, target or authorization boundaries, side effects, or the verification +needed to establish those boundaries remain materially unresolved, **do not merge, +enable, or include the capability in official packages or distributions**. Keep +the proposal pending, narrow it, or decline it. Record unknowns explicitly; do not +turn them into "none" or assumed safety. Passing CI, completed implementation, +upstream popularity, or silence from maintainers is not scope approval. + +For a new provider or a capability involving target execution, injection, +mutation, or protection recovery, seek an explicit maintainer scope decision in +the linked issue or design discussion before substantial implementation intended +for integration. Small exploratory prototypes and draft PRs may help inform that +decision; they do not authorize integration. The decision must cover the final +proposed behavior; a scope expansion needs a new decision. Routine fixes and +documentation changes within existing boundaries do not need a separate advance +decision, but must not silently expand those boundaries. + +### Starting a proposal + +To open a feature request or design discussion, describe the problem and desired +outcome or decision. Short answers and the forms' "Not sure yet" options are +welcome. You do not need code, a complete design or test plan, legal analysis, or +private proof-of-authorization documents to start a discussion. Share what you +know and leave optional details blank when you need help. + +Form selections guide triage; they are not proof of authorization or scope +approval. Unselected effects must not be treated as established absence. +Maintainers and implementers work together to resolve the following before +integration: + +- The concrete analyst question, requested inputs and outputs, and why existing + REA operations are insufficient. +- Why the workflow fits this scope, its non-goals, and any protection recovery, + bypass, or attack automation it would expose. +- The selected targets and every execution, injection, mutation, network, or + persistence effect, including behavior inherited from upstream tools. +- How the boundaries and outcome will be verified with self-owned or appropriately + licensed samples; identify real CLI/MCP and provider coverage, cleanup and + resource checks, and remaining unknowns. Do not publish private binaries, + credentials, or sensitive target data; follow [SECURITY.md](SECURITY.md) for + private vulnerability reports. + +Mixed proposals should separate acceptable analysis capabilities from excluded +workflows so each can be decided independently. An issue may discuss unresolved +questions; opening an issue or PR does not authorize integration. This is a +maintainer integration gate, not a per-call confirmation requirement or a sandbox +guarantee. + ## Development setup Use the development toolchain pinned in `.nvmrc` and From 94153f1c757e07a6d092e00903aa8b125d4a0afa Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:38:04 +0800 Subject: [PATCH 11/93] fix(uninstall): label YAML configuration syntax failures correctly Report malformed Hermes and DeepSeek Harness documents as YAML. Carry the compiled setup/uninstall diagnostics coverage from #2153 onto the existing validation from #2157, including unchanged files and no backup writes. Add malformed YAML scenarios for both clients. Validation: 174 focused tests, npm run check, test discovery, package verification, and npm pack --dry-run. Co-authored-by: Jax367 <239124526+Jax367@users.noreply.github.com> --- src/application/Uninstall.ts | 2 +- .../clientConfigurationDiagnosticsCli.test.ts | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 570b5589e0..c410280bca 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -289,7 +289,7 @@ const readClientConfiguration = async ( "failed", cause instanceof ClientConfigurationShapeError ? `Configuration was not changed: ${cause.message}. Repair it, then rerun uninstall.` - : `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, + : `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : client.format === "hermes" || client.format === "dsh" ? "YAML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, ), ); } diff --git a/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts b/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts new file mode 100644 index 0000000000..d9363988f8 --- /dev/null +++ b/tests/acceptance/setup/clientConfigurationDiagnosticsCli.test.ts @@ -0,0 +1,154 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + client: "cursor", + path: ".cursor/mcp.json", + text: "[]", + diagnostic: "The configuration root must be an object", + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: "null", + diagnostic: "The configuration root must be an object", + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: '{"mcpServers":null,"keep":1}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: '{"mcpServers":"x","keep":1}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "pi", + path: ".pi/agent/mcp.json", + text: '{"mcpServers":[]}', + diagnostic: '"mcpServers" must be an object', + }, + { + client: "codex", + path: ".codex/config.toml", + text: 'mcp_servers = "x"\n', + diagnostic: '"mcp_servers" must be an object', + }, + { + client: "opencode", + path: ".config/opencode/opencode.json", + text: '{"mcp":{"servers":null}}', + diagnostic: '"mcp.servers" must be an object', + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "- item\n", + diagnostic: "The configuration root must be an object", + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "mcp_servers: null\nkeep: 1\n", + diagnostic: '"mcp_servers" must be an object', + }, + { + client: "cursor", + path: ".cursor/mcp.json", + text: "not-json", + diagnostic: "Configuration is not valid JSON", + syntax: "JSON", + }, + { + client: "hermes", + path: ".hermes/config.yaml", + text: "mcp_servers: [\n", + diagnostic: "Configuration is not valid YAML", + syntax: "YAML", + }, + { + client: "dsh", + path: ".dsh/cordis.patch.yml", + text: "- insert: [\n", + diagnostic: "Configuration is not valid YAML", + syntax: "YAML", + }, +] as const; + +for (const scenario of cases) + cliTest( + `setup and uninstall explain ${scenario.client} configuration ${scenario.text}`, + async ({ cli }) => { + const home = await createTestTempDirectory( + "rea-client-config-diagnostic-", + ); + const config = join(home, scenario.path); + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, scenario.text); + const environment = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + HERMES_HOME: join(home, ".hermes"), + DSH_HOME: join(home, ".dsh"), + }; + const setup = await cli.run({ + arguments: [ + "setup", + "--client", + scenario.client, + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment, + }); + expect(setup.exitCode).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + }); + if ("syntax" in scenario) { + expect(setup.json).toMatchObject({ remediation: expect.any(String) }); + if (scenario.syntax === "JSON") + expect(setup.stdout).toContain("Invalid JSON/JSONC"); + } else { + expect(setup.json).toMatchObject({ + remediation: expect.stringContaining(scenario.diagnostic), + }); + expect(setup.stdout).not.toContain("invalid_type"); + } + const uninstall = await cli.run({ + arguments: ["uninstall", "--json"], + cwd: home, + environment, + }); + expect(uninstall.exitCode).toBe(1); + expect(uninstall.json).toMatchObject({ + status: "failed", + items: expect.arrayContaining([ + expect.objectContaining({ + name: scenario.client, + status: "failed", + detail: expect.stringContaining(scenario.diagnostic), + }), + ]), + }); + if (!("syntax" in scenario)) + expect(uninstall.stdout).not.toContain("not valid JSON"); + expect(await readFile(config, "utf8")).toBe(scenario.text); + expect(await readdir(dirname(config))).toEqual([ + scenario.path.split("/").at(-1), + ]); + }, + ); From 428eee00fdd0406cdf2b734c9ea14a2bb02da279 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:42:47 +0800 Subject: [PATCH 12/93] ci: keep validation fresh and preserve failed-shard reports (#2210) Run the existing README consistency and Python producer guards from their CI owners. Execute full local validation fresh while preserving cached preparation, and permit scoped reuse of earlier portable build entries. Collect failed-shard diagnostics before enforcing success and publish compact, portable timing history only after a complete successful merge. Preserve installer truncation failure across supported Bash versions. Keep cancellation tests focused on the called MCP tool, observe server progress before timing target readiness, and retain delayed-startup, cancellation, cleanup, and live follow-up request assertions. Validation: all 32 CI jobs and the separate Go lane passed; 935 files, 9,630 cases, 9,544 passed, 86 skipped, zero failed. Controlled regressions and fault/restoration probes are recorded in PR #2210. Refs #2190. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- .../actions/portable-build-cache/action.yml | 5 +- .github/workflows/ci.yml | 55 +++--- CONTRIBUTING.md | 4 + docs/testing.md | 35 +++- install.sh | 36 ++-- package.json | 5 +- scripts/ci/test-report.mjs | 73 ++++++++ .../process/processFinalization.test.ts | 48 +++-- .../boundary/filesystem/ciChangeScope.test.ts | 8 + .../boundary/filesystem/ciTestReports.test.ts | 174 ++++++++++++++++++ .../boundary/filesystem/installScript.test.ts | 7 +- .../boundary/filesystem/testTaskCache.test.ts | 108 +++++++++++ tests/fixtures/localToolsMcp.ts | 16 +- tests/fixtures/processFinalization.mjs | 8 +- turbo.json | 21 +-- 15 files changed, 511 insertions(+), 92 deletions(-) create mode 100644 scripts/ci/test-report.mjs create mode 100644 tests/boundary/filesystem/ciTestReports.test.ts create mode 100644 tests/boundary/filesystem/testTaskCache.test.ts diff --git a/.github/actions/portable-build-cache/action.yml b/.github/actions/portable-build-cache/action.yml index 0fb8903c12..ad127ce3dc 100644 --- a/.github/actions/portable-build-cache/action.yml +++ b/.github/actions/portable-build-cache/action.yml @@ -11,7 +11,7 @@ runs: id: plan shell: bash env: - BUILD_CACHE_PREFIX: rea-portable-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc') }} + BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }} run: | npm run --silent test:prepare -- --dry=json | node --input-type=module -e ' @@ -25,6 +25,7 @@ runs: }).sort(); const hash = createHash("sha256").update(hashes.join("\n")).digest("hex"); console.log(`key=${process.env.BUILD_CACHE_PREFIX}-${hash}`); + console.log(`prefix=${process.env.BUILD_CACHE_PREFIX}-`); ' >> "$GITHUB_OUTPUT" - name: Restore portable build outputs if: inputs.save != 'true' @@ -32,9 +33,11 @@ runs: with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} + restore-keys: ${{ steps.plan.outputs.prefix }} - name: Restore and save portable build outputs if: inputs.save == 'true' uses: actions/cache@v4 with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} + restore-keys: ${{ steps.plan.outputs.prefix }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20824bae33..60920f0de5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: run: npm run check:ci - if: needs.changes.outputs.code == 'true' run: npm run verify:test-discovery + - name: Check LLDB producer retention and launch contracts + if: needs.changes.outputs.code == 'true' + run: npm run test:python - if: needs.changes.outputs.code != 'true' run: npm run format:check @@ -168,7 +171,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v4 with: - name: test-results + name: test-timings run-id: ${{ steps.history.outputs.run-id }} github-token: ${{ github.token }} path: .cache/ci-test-history @@ -203,14 +206,10 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest steps: - - name: Require successful test shards + - name: Require successful change classification env: CHANGES_RESULT: ${{ needs.changes.result }} - CODE_REQUIRED: ${{ needs.changes.outputs.code }} - SHARD_RESULT: ${{ needs.test-shard.result }} - run: | - test "${CHANGES_RESULT}" = "success" - test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success" + run: test "${CHANGES_RESULT}" = "success" - uses: actions/checkout@v4 if: needs.changes.outputs.code == 'true' && success() - uses: actions/setup-node@v4 @@ -227,6 +226,7 @@ jobs: pattern: vitest-report-* merge-multiple: true - name: Merge coverage, JUnit, and timing reports + id: merge if: needs.changes.outputs.code == 'true' && success() run: >- npm run test:ci:merge -- @@ -240,27 +240,13 @@ jobs: --outputFile.junit=test-results.xml --outputFile.json=test-results.json - name: Summarize slowest test files - if: needs.changes.outputs.code == 'true' && success() - shell: bash - run: | - node <<'NODE' >> "${GITHUB_STEP_SUMMARY}" - const report = JSON.parse(require("node:fs").readFileSync("test-results.json", "utf8")); - const files = (report.testResults ?? []) - .map((result) => ({ - name: result.name, - duration: Math.max(0, (result.endTime ?? result.startTime) - result.startTime), - })) - .sort((left, right) => right.duration - left.duration) - .slice(0, 20); - console.log("## Slowest deterministic test files\n"); - console.log("| File | Duration (ms) |"); - console.log("| --- | ---: |"); - for (const file of files) { - console.log(`| \`${file.name}\` | ${file.duration} |`); - } - NODE + if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' + env: + SHARD_RESULT: ${{ needs.test-shard.result }} + MERGE_RESULT: ${{ steps.merge.outcome }} + run: node scripts/ci/test-report.mjs - uses: actions/upload-artifact@v4 - if: needs.changes.outputs.code == 'true' && always() + if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' with: name: test-results path: | @@ -269,6 +255,21 @@ jobs: coverage/ if-no-files-found: error retention-days: 30 + - name: Publish successful timing history + if: needs.changes.outputs.code == 'true' && success() && needs.test-shard.result == 'success' + uses: actions/upload-artifact@v4 + with: + name: test-timings + path: .cache/ci-test-history/test-results.json + include-hidden-files: true + if-no-files-found: error + retention-days: 30 + - name: Require successful test shards + if: ${{ !cancelled() }} + env: + CODE_REQUIRED: ${{ needs.changes.outputs.code }} + SHARD_RESULT: ${{ needs.test-shard.result }} + run: test "${CODE_REQUIRED}" != "true" || test "${SHARD_RESULT}" = "success" conformance-fixtures: name: Source-built conformance fixtures diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dc0dd4204..519e83cc95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,6 +182,10 @@ CI diagnosis. Packaging/setup/distribution changes additionally require `npm run verify:package` and `npm pack --dry-run`; provider changes require the matching real-provider lane. State which host/provider workflows ran. +`check:pr` reuses build and static-check outputs while executing the test suite +fresh, including the fast Python producer checks. `docs:check` also runs the +onboarding example consistency suite for README-only changes. + Pre-commit formats and lints staged files; pre-push runs `check:fast`. ### Generated files and command ownership diff --git a/docs/testing.md b/docs/testing.md index d0748b7605..b80ff8f787 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,11 +19,12 @@ behavior, and complete CI evidence before merging. | `npm run test:boundary` | Boundary, CLI boundary, MCP boundary, process boundary, and process-global projects | | `npm run test:mcp` | MCP boundary project | | `npm run test:acceptance` | Complete compiled CLI and MCP acceptance workflows | +| `npm run test:python` | LLDB producer retention and launch contracts with a fake LLDB API; no native debugger required | | `npm run test:watch` | Dirty source tests in watch mode, without build | | `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests | | `npm run check:changed` | Cached typecheck/lint and branch-related source feedback | | `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks | -| `npm run docs:check` | Generated-document validation from current source and build outputs | +| `npm run docs:check` | Generated-document validation and fresh onboarding example consistency checks | For example: @@ -97,6 +98,12 @@ runner. Preserve simultaneous launches where concurrency is itself under test. Build/documentation writers hold checkout-local output locks. Tests do not hold a broad command lock; `check:pr` finishes tests before document validation. +Turbo caches deterministic preparation and static checks, but `test:run` always +executes. Repository files do not fully describe installed tool contents or host +state. A previous test pass therefore cannot replace current validation, even +when the source tree is unchanged. `check:pr` also runs the fast Python producer +suite; CI runs it in the static job for code changes. These producer tests do not +establish real LLDB engine coverage. Vitest's persistent compile cache remains disabled. The `cliTest` fixture shares a temporary Node compile cache among its fresh CLI subprocesses. The worker @@ -247,17 +254,25 @@ Coverage does not replace named boundary or real-provider scenarios. CI runs six native Vitest shards without retries. Each shard emits a blob report; the merge job produces aggregate coverage plus JUnit and JSON timing -reports, uploads them together, and writes the slowest files to the workflow -summary. Static checks, documentation, build, package verification, and +reports even when a shard fails, uploads available diagnostics, and writes the +slowest files to the workflow summary. Partial reports are marked diagnostic +only. Failed, cancelled, or missing selected shards still fail the required gate, +and aggregate coverage thresholds remain enforced by the merge command. +Only successful complete merges publish the small `test-timings` artifact, with +checkout-relative paths; the build job downloads that artifact for the sequencer +instead of the coverage/JUnit bundle. Missing history falls back to native +sharding. Static checks, documentation, build, package verification, and real-system lanes remain separate jobs so one kind of evidence cannot stand in for another. The build job saves portable Turbo outputs across runs. Eight Linux real-system lanes restore this cache without waiting for the build job. The shared action derives its key from `test:prepare --dry=json` task hashes, runner OS and -architecture, and `.npmrc`; test-only changes can reuse compiled outputs. Every +architecture, `.npmrc`, `.nvmrc`, and the lockfile; test-only changes can reuse compiled outputs. Every build and verification command still runs, so Turbo checks task inputs and -rebuilds misses. There is no prefix fallback to accumulate older build outputs. +rebuilds misses. A restore prefix with the same platform and toolchain permits +partial task reuse when one generator's inputs change; Turbo still invalidates +that generator's entry. Native outputs are not stored in this cache. Native package jobs, the Windows curated lane, and the Apple artifact lane reuse the same workflow's Turbo cache for portable compiled JavaScript, skills, and @@ -266,9 +281,10 @@ commands, which restore matching outputs or rebuild on a cache miss. Native artifacts, dependency installs, package archives, and runtime verification remain host-local. Keep platform-dependent outputs out of this shared cache. -The PR acceptance target is a median `npm run check:pr` wall time below three -minutes across three warm-build runs on the benchmark host. Keep Vitest caches -cold unless separately identified. A PR that touches packaging or real-system +Record cold and warm preparation separately from fresh test execution when +benchmarking the optional `npm run check:pr` gate. Compare multiple runs on the +same host, including subprocess and queue costs; a cached pass is not a test +runtime measurement. A PR that touches packaging or real-system behavior requires the applicable `verify:*` lanes; packaging and installation changes also require `npm run verify:package`. The full-gate benchmark measures that explicit lane, not the routine iteration requirement. @@ -682,7 +698,8 @@ not make native host acceptance unavailable. Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `native/windows/README.md`, or the GitHub issue and pull-request templates run formatting and -generated-document validation without the source-test shards or native package +generated-document validation, including the small onboarding example consistency +suite, without the source-test shards or native package lanes. Classification compares the PR head with its merge base, so later base-branch changes do not expand that scope. Static and coverage aggregate jobs remain present and fail if classification fails. Every code change retains the diff --git a/install.sh b/install.sh index 8790bb8df9..46260fe715 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,14 @@ fail() { exit 1 } +# Bash can return status 0 while parsing an incomplete [[ condition. Keep that +# grammar in a constant quoted expression so truncated downloads fail while +# parsing the outer brace group. eval parses only this literal; the arguments +# are expanded as data by the conditional, not interpolated into shell code. +matches() { + eval '[[ "$1" =~ $2 ]]' +} + # Resolve the current host's Node.js tarball label pair; a label we cannot map # is kept verbatim so the operator can still read it. Falls back on the raw # `uname` outputs rather than inventing a label the Node.js release index does @@ -95,41 +103,41 @@ main() { esac done - if [[ "$verbose" == true ]]; then + if [ "$verbose" = true ]; then set -x fi platform="$(uname -s)" - [[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions." + [ "$platform" = "Darwin" ] || [ "$platform" = "Linux" ] || fail "REA supports macOS and selected 64-bit Linux distributions." command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer." command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry." command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry." node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" IFS=. read -r node_major node_minor _node_patch <<<"$node_version" - [[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." - if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] || + matches "$node_major" '^[0-9]+$' && matches "$node_minor" '^[0-9]+$' || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." + if ! matches "$_node_patch" '^[0-9]+(\+[0-9A-Za-z.-]+)?$' || ! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then fail_node_version "$node_version" fi - if [[ -n "$version" ]]; then - [[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version." + if [ -n "$version" ]; then + matches "$version" "^${version_pattern}$" || fail "version must be an exact semantic version." else release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry." tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION." - [[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION." + matches "$tag" "^rea-agents-(${version_pattern})$" || fail "the latest release tag was invalid. Retry later or pass --version VERSION." version="${BASH_REMATCH[1]}" fi # npm's exact-version resolver cleans build metadata from the requested version, # so it may install the base release and make the post-install identity check fail. - if [[ "$version" =~ \+ ]]; then + if matches "$version" '\+'; then fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry." fi install_args=(install --global) - if [[ "$platform" == "Linux" ]]; then + if [ "$platform" = "Linux" ]; then install_args+=(--prefix "$HOME/.local") install_bin="$HOME/.local/bin/rea" else @@ -145,21 +153,21 @@ main() { printf ' %q' npm "${install_args[@]}" printf '\n' printf ' Binary: %s\n' "$install_bin" - printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')" + printf ' Setup: %s\n' "$([ "$start_setup" = true ] && printf 'start when a terminal is available' || printf 'skipped')" - if [[ "$dry_run" == true ]]; then + if [ "$dry_run" = true ]; then printf 'Dry run complete; no changes made.\n' exit 0 fi printf 'Installing %s@%s...\n' "$PACKAGE" "$version" npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry." - [[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." + [ -x "$install_bin" ] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry." - [[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." + [ "$installed_version" = "$version" ] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." printf 'REA %s is installed.\n' "$version" - if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then + if [ "$start_setup" = true ] && [ -r /dev/tty ] && [ -w /dev/tty ] && [ -t 1 ]; then printf 'Starting REA setup...\n' "$install_bin" setup /dev/tty else diff --git a/package.json b/package.json index ea47ce8509..e5de6a3dbe 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "precheck:test": "npm run deps:check", "check:test": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", - "check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos", + "check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos", "precheck:ci": "npm run deps:check", "check:ci": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos && npm run verify:architecture-guards", "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills", @@ -128,6 +128,7 @@ "test": "npm run test:with-build", "test:with-build": "npm run test:prepare && vitest run", "test:run": "vitest run", + "test:python": "python3 -m unittest bridge/native/test_rea_lldb_tracer.py -v", "test:local": "node scripts/run-development-tests.mjs local", "test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation", "pretest:boundary": "npm run test:prepare", @@ -231,7 +232,7 @@ "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare", - "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built", + "docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts", "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check", "docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema", "prepack": "node scripts/prepack.mjs", diff --git a/scripts/ci/test-report.mjs b/scripts/ci/test-report.mjs new file mode 100644 index 0000000000..a3686d6ed0 --- /dev/null +++ b/scripts/ci/test-report.mjs @@ -0,0 +1,73 @@ +import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { z } from "zod"; + +const root = process.cwd(); +const historyDirectory = join(root, ".cache/ci-test-history"); +const historyPath = join(historyDirectory, "test-results.json"); +// Never let a failed or partial rerun leave a previous successful history file. +await rm(historyPath, { force: true }); +const report = z + .object({ + success: z.boolean(), + numFailedTests: z.number().int().nonnegative(), + testResults: z.array( + z.object({ + name: z.string().min(1), + startTime: z.number().finite(), + endTime: z.number().finite(), + }), + ), + }) + .parse(JSON.parse(await readFile("test-results.json", "utf8"))); + +const files = report.testResults.map(({ name, startTime, endTime }) => { + const path = relative(root, resolve(root, name)); + if ( + path === "" || + path === ".." || + path.startsWith(".." + sep) || + isAbsolute(path) + ) + throw new Error(`Test report path is outside the checkout: ${name}`); + const duration = endTime - startTime; + if (!Number.isFinite(duration) || duration < 0) + throw new Error(`Test report has an invalid duration: ${name}`); + return { name: path.split(sep).join("/"), startTime: 0, endTime: duration }; +}); +const complete = process.env.SHARD_RESULT === "success"; +const passed = + complete && + process.env.MERGE_RESULT === "success" && + report.success && + report.numFailedTests === 0; +const summary = [ + complete ? "## Merged test diagnostics" : "## Partial test diagnostics", + "", + `Shard result: ${process.env.SHARD_RESULT ?? "unknown"}; merge result: ${process.env.MERGE_RESULT ?? "unknown"}.`, + passed + ? "Successful timing history is available." + : "This report is diagnostic only and is not a successful timing baseline.", + "", + "| Slowest file | Duration (ms) |", + "| --- | ---: |", + ...files + .toSorted((left, right) => right.endTime - left.endTime) + .slice(0, 20) + .map( + ({ name, endTime }) => `| ${name.replaceAll("|", "\\|")} | ${endTime} |`, + ), + "", +].join("\n"); +if (process.env.GITHUB_STEP_SUMMARY) + await appendFile(process.env.GITHUB_STEP_SUMMARY, summary); +else console.log(summary); + +if (passed) { + await mkdir(historyDirectory, { recursive: true }); + await writeFile( + historyPath, + JSON.stringify({ success: true, numFailedTests: 0, testResults: files }), + ); +} diff --git a/tests/acceptance/process/processFinalization.test.ts b/tests/acceptance/process/processFinalization.test.ts index 65690bf5ca..7dcb4e969e 100644 --- a/tests/acceptance/process/processFinalization.test.ts +++ b/tests/acceptance/process/processFinalization.test.ts @@ -30,7 +30,7 @@ type CaptureOutcome = | { readonly kind: "partial"; readonly partial: unknown }; const captureScenario = ( - mode: "cooperative" | "ignoring" | "descendant", + mode: "cooperative" | "ignoring" | "delayed-ignoring" | "descendant", root: string, additions: CaptureScenario = {}, finalExitCode?: number, @@ -453,11 +453,13 @@ captureTest( "rea-finalization-cli-cancel-", ); const cliScenarioPath = join(cliRoot, "scenario.json"); + // Match the other finalization cases' observation window. The old 300 ms + // deadline could signal the fixture before it installed its handlers. await writeFile( cliScenarioPath, JSON.stringify( - captureScenario("ignoring", cliRoot, { - timeout_ms: 300, + captureScenario("delayed-ignoring", cliRoot, { + timeout_ms: 1_500, finalization_ms: 20_000, }), ), @@ -527,18 +529,36 @@ captureTest( const mcpRoot = await createTestTempDirectory( "rea-finalization-mcp-cancel-", ); - const { client } = await connectLocalToolsMcp(); + const { client, call: callTool } = await connectLocalToolsMcp(); const controller = new AbortController(); - const call = client.callTool( - { - name: "capture_process_scenario", - arguments: captureScenario("ignoring", mcpRoot, { - timeout_ms: 300, - finalization_ms: 20_000, - }), - }, - { signal: controller.signal }, + let reportStarted: (() => void) | undefined; + const captureStarted = new Promise((resolve) => { + reportStarted = resolve; + }); + const call = callTool( + "capture_process_scenario", + captureScenario("delayed-ignoring", mcpRoot, { + timeout_ms: 1_500, + finalization_ms: 20_000, + }), + { signal: controller.signal, onprogress: () => reportStarted?.() }, ); + // Observe rejection immediately: a failed readiness wait must not leave a + // pending request to reject unhandled when the fixture closes its client. + // The original promise remains available for the cancellation assertion. + const observedCall = call.catch(() => undefined); + onTestFinished(async () => { + controller.abort(); + await observedCall; + }); + // Start the fixture's readiness wait after the server begins this capture; + // SDK schema preparation before dispatch is not target startup time. + await Promise.race([ + captureStarted, + call.then(() => { + throw new Error("MCP capture completed before reporting startup"); + }), + ]); const mcpFixturePid = await waitForFinalization(mcpRoot); const mcpStarted = Date.now(); controller.abort(new Error("cancel finalization acceptance fixture")); @@ -548,7 +568,7 @@ captureTest( ).rejects.toThrow("cancel finalization acceptance fixture"); await waitForProcessExit(mcpFixturePid); await expect( - client.listTools(), + client.listTools(undefined, { cacheMode: "refresh" }), "the server accepts a follow-up request after cancelling the capture", ).resolves.toMatchObject({ tools: expect.any(Array) }); expect( diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index e655754966..f1b16a52c6 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -312,6 +312,14 @@ it.each([ "tests/boundary/process/darwinProcessRunTokenReader.test.ts", { code: true, apple: true, package: false, runtime: false }, ], + [ + "tests/boundary/cli/nativePlistCli.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/boundary/filesystem/windowsNpxRegistration.test.ts", + { code: true, package: true, runtime: true, apple: false }, + ], [ "src/process/ProcessOwnership.test.ts", { code: true, package: true, apple: false, evm: false, browser: false }, diff --git a/tests/boundary/filesystem/ciTestReports.test.ts b/tests/boundary/filesystem/ciTestReports.test.ts new file mode 100644 index 0000000000..0c9bb4ccf9 --- /dev/null +++ b/tests/boundary/filesystem/ciTestReports.test.ts @@ -0,0 +1,174 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { parseTestDurations } from "../../ciTestSequencer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const script = new URL("../../../scripts/ci/test-report.mjs", import.meta.url); +const stepsSchema = z.array( + z.object({ + name: z.string().optional(), + run: z.string().optional(), + uses: z.string().optional(), + if: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + with: z.record(z.string(), z.unknown()).optional(), + }), +); +const workflow = async () => + z + .object({ + jobs: z.object({ + test: z.object({ steps: stepsSchema }), + build: z.object({ steps: stepsSchema }), + docs: z.object({ steps: stepsSchema }), + static: z.object({ steps: stepsSchema }), + }), + }) + .parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ).jobs; + +it("collects failed-shard diagnostics before enforcing the shard result", async () => { + const steps = (await workflow()).test!.steps; + const merge = steps.findIndex((step) => step.run?.includes("test:ci:merge")); + const upload = steps.findIndex((step) => step.with?.name === "test-results"); + const gate = steps.findIndex( + (step) => step.env?.SHARD_RESULT && step.run?.includes("test "), + ); + expect(merge).toBeGreaterThan(0); + expect(upload).toBeGreaterThan(merge); + expect(gate).toBeGreaterThan(upload); + expect(steps[merge]?.if).not.toContain("needs.test-shard.result"); + const command = steps[gate]?.run; + if (command === undefined) throw new Error("Missing shard result gate"); + for (const result of ["failure", "cancelled", "skipped", ""]) { + await expect( + execute("bash", ["-c", command], { + env: { ...process.env, CODE_REQUIRED: "true", SHARD_RESULT: result }, + }), + ).rejects.toMatchObject({ code: 1 }); + } + await expect( + execute("bash", ["-c", command], { + env: { ...process.env, CODE_REQUIRED: "false", SHARD_RESULT: "skipped" }, + }), + ).resolves.toMatchObject({ stderr: "" }); +}); + +it("keeps diagnosis available after merge failure and publishes only complete successful history", async () => { + const steps = (await workflow()).test!.steps; + const diagnosis = steps.find((step) => step.with?.name === "test-results"); + expect(diagnosis?.if).toContain("!cancelled()"); + expect(diagnosis?.if).not.toContain("success()"); + const history = steps.find((step) => step.with?.name === "test-timings"); + expect(history?.if).toContain("success()"); + expect(history?.if).toContain("needs.test-shard.result == 'success'"); + expect(history?.with?.path).toBe(".cache/ci-test-history/test-results.json"); + const download = (await workflow()).build!.steps.find( + (step) => step.name === "Download previous timing report", + ); + expect(download?.with?.name).toBe(history?.with?.name); +}); + +it("runs the existing onboarding and Python producer guards from their selected CI owners", async () => { + const jobs = await workflow(); + const scripts = z + .object({ scripts: z.record(z.string(), z.string()) }) + .parse( + JSON.parse( + await readFile( + new URL("../../../package.json", import.meta.url), + "utf8", + ), + ), + ).scripts; + expect( + jobs.docs.steps.some((step) => step.run === "npm run docs:check"), + ).toBe(true); + expect(scripts["docs:check"]).toContain( + "tests/conformance/readmeConsistency.test.ts", + ); + const producer = jobs.static.steps.find( + (step) => step.run === "npm run test:python", + ); + expect(producer?.if).toBe("needs.changes.outputs.code == 'true'"); + expect(scripts["test:python"]).toContain( + "bridge/native/test_rea_lldb_tracer.py", + ); +}); + +it("exports portable timing history and removes it on a failed, partial, or invalid rerun", async () => { + const root = await createTestTempDirectory("rea-ci-report-"); + const summary = join(root, "summary.md"); + const history = join(root, ".cache/ci-test-history/test-results.json"); + const report = { + success: true, + numFailedTests: 0, + testResults: [ + { + name: join(root, "tests/slow.test.ts"), + startTime: 10, + endTime: 110, + assertionResults: [{ large: "not timing data" }], + }, + { name: join(root, "tests/fast.test.ts"), startTime: 20, endTime: 30 }, + ], + }; + const run = async (value: unknown, shards = "success", merge = "success") => { + await writeFile(join(root, "test-results.json"), JSON.stringify(value)); + await writeFile(summary, ""); + return execute(process.execPath, [fileURLToPath(script)], { + cwd: root, + env: { + ...process.env, + GITHUB_STEP_SUMMARY: summary, + SHARD_RESULT: shards, + MERGE_RESULT: merge, + }, + }); + }; + await run(report); + const contents = await readFile(history, "utf8"); + expect(contents).not.toContain("assertionResults"); + expect(contents).not.toContain(root); + expect( + parseTestDurations(contents, resolve(root, "different-checkout")), + ).toEqual( + new Map([ + ["tests/slow.test.ts", 100], + ["tests/fast.test.ts", 10], + ]), + ); + for (const [value, shards, merge] of [ + [{ ...report, success: false, numFailedTests: 1 }, "failure", "failure"], + [report, "cancelled", "success"], + [report, "success", "failure"], + ] as const) { + await run(report); + await run(value, shards, merge); + await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(summary, "utf8")).toContain("diagnostic only"); + } + await run(report); + await expect( + run({ + ...report, + testResults: [{ name: "../outside.test.ts", startTime: 1, endTime: 3 }], + }), + ).rejects.toMatchObject({ code: 1 }); + await expect(readFile(history)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts index 0c45c12347..3fbcf7a3c0 100644 --- a/tests/boundary/filesystem/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -334,13 +334,18 @@ describe("native macOS installer", { timeout: 20_000 }, () => { }); describe("installer semantic version parsing", { timeout: 20_000 }, () => { - it.each(["1.2.3-01"])( + it.each([ + "1.2.3-01", + "1.2.3-$(printf unexpected)", + '1.2.3-"; printf unexpected; #', + ])( "rejects malformed semantic versions before invoking npm: %s", async (version) => { const fixture = await createFixture(); await expect( runInstaller(fixture, ["--version", version, "--dry-run"]), ).rejects.toMatchObject({ + stdout: "", stderr: "REA installation failed: version must be an exact semantic version.\n", }); diff --git a/tests/boundary/filesystem/testTaskCache.test.ts b/tests/boundary/filesystem/testTaskCache.test.ts new file mode 100644 index 0000000000..7e06ce1a1a --- /dev/null +++ b/tests/boundary/filesystem/testTaskCache.test.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { workflowGit } from "../../support/workflowGit.js"; + +const execute = promisify(execFile); + +it.skipIf(process.platform === "android")( + "reruns test validation after repository or external input changes while reusing preparation", + async () => { + const config = z + .object({ + tasks: z.record(z.string(), z.record(z.string(), z.unknown())), + }) + .parse( + JSON.parse( + await readFile( + new URL("../../../turbo.json", import.meta.url), + "utf8", + ), + ), + ); + const testTask = config.tasks["test:run"]; + const dependencies = z.array(z.string()).parse(testTask?.dependsOn); + const root = await createTestTempDirectory("rea-test-cache-"); + const external = await createTestTempDirectory("rea-test-dependency-"); + const dependency = join(external, "provider.json"); + const scripts: Record = { "test:run": "node check.mjs" }; + const tasks: Record = { "test:run": testTask }; + for (const [index, task] of dependencies.entries()) { + scripts[task] = `node prepare.mjs ${index}`; + tasks[task] = { + inputs: ["artifact-input.txt"], + outputs: [`prepared-${index}.txt`], + }; + } + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-cache-fixture", + version: "1.0.0", + private: true, + type: "module", + packageManager: "npm@11.16.0", + scripts, + }), + ); + await writeFile( + join(root, "package-lock.json"), + JSON.stringify({ + name: "rea-cache-fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { "": { name: "rea-cache-fixture", version: "1.0.0" } }, + }), + ); + await writeFile(join(root, "turbo.json"), JSON.stringify({ tasks })); + await writeFile( + join(root, "artifact-input.txt"), + "unchanged preparation input", + ); + await writeFile(join(root, "README_zh.md"), "aligned"); + await writeFile(dependency, "healthy"); + await writeFile( + join(root, "prepare.mjs"), + 'import { appendFileSync } from "node:fs"; appendFileSync(`prepared-${process.argv[2]}.txt`, "prepared\\n");', + ); + await writeFile( + join(root, "check.mjs"), + `import { readFileSync } from "node:fs"; if(readFileSync("README_zh.md","utf8") !== "aligned" || readFileSync(${JSON.stringify(dependency)},"utf8") !== "healthy") throw new Error("current input is invalid");`, + ); + await workflowGit(root, ["init", "--initial-branch=main"]); + await workflowGit(root, ["add", "."]); + await workflowGit(root, ["commit", "-m", "test: seed cache fixture"]); + const run = () => + execute( + process.execPath, + [ + join(process.cwd(), "node_modules/turbo/bin/turbo"), + "run", + "test:run", + "--output-logs=errors-only", + ], + { cwd: root, env: { ...process.env, TURBO_DAEMON: "false" } }, + ); + await run(); + await writeFile(join(root, "README_zh.md"), "wrong command"); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await writeFile(join(root, "README_zh.md"), "aligned"); + await run(); + await writeFile(dependency, "faulty"); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await expect(run()).rejects.toMatchObject({ code: 1 }); + await writeFile(dependency, "healthy"); + await run(); + for (const index of dependencies.keys()) { + expect(await readFile(join(root, `prepared-${index}.txt`), "utf8")).toBe( + "prepared\n", + ); + } + }, +); diff --git a/tests/fixtures/localToolsMcp.ts b/tests/fixtures/localToolsMcp.ts index bdfc3b84c3..1e1e91909b 100644 --- a/tests/fixtures/localToolsMcp.ts +++ b/tests/fixtures/localToolsMcp.ts @@ -1,4 +1,7 @@ -import { Client } from "@modelcontextprotocol/client"; +import { + Client, + type CallToolRequestOptions, +} from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Ajv2020 } from "ajv/dist/2020.js"; import type { ValidateFunction } from "ajv"; @@ -69,12 +72,19 @@ export async function connectLocalToolsMcp() { validatedSchemas.add(serialized); } const definitions = new Map(tools.map((tool) => [tool.name, tool])); - const call = async (name: string, arguments_: Record) => { + const call = async ( + name: string, + arguments_: Record, + options?: Pick, + ) => { const definition = definitions.get(name); if (definition !== undefined) compileValidator(definition.inputSchema); const response = await client.callTool( { name, arguments: arguments_ }, - definition === undefined ? undefined : { toolDefinition: definition }, + { + ...options, + ...(definition === undefined ? {} : { toolDefinition: definition }), + }, ); if ( response.structuredContent !== undefined && diff --git a/tests/fixtures/processFinalization.mjs b/tests/fixtures/processFinalization.mjs index f70aa0d8bd..b51d2ea61e 100644 --- a/tests/fixtures/processFinalization.mjs +++ b/tests/fixtures/processFinalization.mjs @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; // Writes a periodic report, then behaves by mode: @@ -8,11 +9,16 @@ import { fileURLToPath } from "node:url"; // and exits cleanly. // - "ignoring": stays alive and swallows SIGTERM and SIGINT, so only SIGKILL // ends the process. +// - "delayed-ignoring": installs those handlers after a slow startup. // - "exits": exits on its own at once. // - "descendant": cooperates while leaving a SIGTERM-ignoring child for owned cleanup. // An optional third argument is the exit code a cooperative finalizer uses. const [mode, directory, finalExitCode = "0"] = process.argv.slice(2); +// Make the cancellation case exercise startup beyond its old 300 ms capture +// window deterministically, rather than relying on a busy CI runner. +if (mode === "delayed-ignoring") await delay(500); + writeFileSync(join(directory, "pid.txt"), String(process.pid)); writeFileSync( join(directory, "periodic.json"), @@ -31,7 +37,7 @@ if (mode === "descendant") { ); } -if (mode === "ignoring") { +if (mode === "ignoring" || mode === "delayed-ignoring") { process.on("SIGTERM", () => { writeFileSync(join(directory, "sigterm-received"), "received"); }); diff --git a/turbo.json b/turbo.json index 50bfacfb34..8de89672db 100644 --- a/turbo.json +++ b/turbo.json @@ -104,31 +104,12 @@ ] }, "test:run": { + "cache": false, "dependsOn": [ "artifacts:mcp-catalog", "artifacts:product-catalog", "artifacts:managed-evidence", "artifacts:skills" - ], - "inputs": [ - ".nvmrc", - "package.json", - "package-lock.json", - "src/**", - "tests/**", - "scripts/**", - "bridge/**", - "vitest.config.ts", - { - "mode": "dependencyOutputs", - "globs": [ - ".cache/mcp-tool-catalog.json", - "skills/**", - "docs/public/product-catalog.json", - "docs/verification/managed-conformance-manifest.json", - "docs/verification/managed-conformance-ledger.json" - ] - } ] }, "docs:check:built": { From 1770f38cfc6cbe87491d0de8b8730c305c5cf8ea Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:04:00 +0800 Subject: [PATCH 13/93] test(native): verify real PE and DOS entry views Record real Ghidra/MCP and Codex CLI verification of shipped ShareX native PE and FreeDOS DOS MZ entry views. Preserve original Evidence provenance, separate run-specific references from facts, and reject stale/colliding producer identities or unsupported model claims. Sixteen focused experiment regression cases, original public-reply/transcript replays, and CI run 38273269072 pass for head 0667f7cb7c85f134311c7470ff4ee99ef9cc45f4. No production API/default change or general native speedup claim; final resource counters remain unknown. Continues #2016. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- experiments/analysis-efficiency/README.md | 4 + experiments/analysis-efficiency/compare.mjs | 16 +- experiments/analysis-efficiency/corpus.json | 30 ++ experiments/analysis-efficiency/measure.mjs | 148 ++------ .../analysis-efficiency/native-entry-views.md | 178 ++++++++++ .../analysis-efficiency/native-entry.mjs | 272 ++++++++++++++ .../questions/native-dos.txt | 1 + .../questions/native-pe.txt | 1 + .../analysis-efficiency/review-model.py | 58 ++- .../cli/nativeEfficiencyComparison.test.ts | 336 ++++++++++++++++++ 10 files changed, 920 insertions(+), 124 deletions(-) create mode 100644 experiments/analysis-efficiency/native-entry-views.md create mode 100644 experiments/analysis-efficiency/native-entry.mjs create mode 100644 experiments/analysis-efficiency/questions/native-dos.txt create mode 100644 experiments/analysis-efficiency/questions/native-pe.txt create mode 100644 tests/boundary/cli/nativeEfficiencyComparison.test.ts diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 9a09e9d365..834d717cd8 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -7,6 +7,10 @@ JSON measurements belong outside the checkout. This document keeps the measured findings, verification scope and revision references; the scripts and `corpus.json` keep the reproduction inputs. Generated `results/` is ignored. +Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md) +records the post-merge native continuation, including a small-function case where +four selected replies cost more bytes than the complete dossier. + The 12 detailed checkpoint receipts were archived byte-for-byte before removal from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the private experiment lab outside the checkout; its `archive-index.json` records diff --git a/experiments/analysis-efficiency/compare.mjs b/experiments/analysis-efficiency/compare.mjs index 8c386a4d93..b6ba1a5528 100644 --- a/experiments/analysis-efficiency/compare.mjs +++ b/experiments/analysis-efficiency/compare.mjs @@ -30,6 +30,7 @@ const sameSettings = [ "provider_search_path_sha256", "provider_environment_policy_sha256", "measure_source_sha256", + "native_workflow_source_sha256", "package_lock_sha256", "node_version", "sdk_client_version", @@ -40,7 +41,9 @@ const sameSettings = [ const settingDifferences = sameSettings.filter( (key) => !isDeepStrictEqual(left[key], right[key]) || - (key === "provider_search_path_sha256" && + ((key === "provider_search_path_sha256" || + (key === "native_workflow_source_sha256" && + typeof left.native_question === "string")) && !/^[a-f0-9]{64}$/.test(left[key] ?? "")), ); // Producer/view IDs identify different delivery observations; a named recovery @@ -54,6 +57,17 @@ const facts = (answer) => const a = facts(leftAnswer); const b = facts(rightAnswer); const runSpecific = []; +for (const [side, answer] of [ + ["left", leftAnswer], + ["right", rightAnswer], +]) { + if (Object.hasOwn(answer, "evidence_ids")) + runSpecific.push({ + side, + path: ["evidence_ids"], + value: answer.evidence_ids, + }); +} // Binwalk assigns a new opaque region UUID in each invocation. Preserve both // original lookup values in this report; compare the observed region facts // separately. The product and original replies retain those IDs unchanged. diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json index 911fa88f5f..5d2ad531dd 100644 --- a/experiments/analysis-efficiency/corpus.json +++ b/experiments/analysis-efficiency/corpus.json @@ -116,6 +116,36 @@ "derivation": "selected regular ZIP member ShareX.HelpersLib.dll; no target execution" } }, + { + "id": "sharex.exe", + "file": "ShareX.exe", + "bytes": 198656, + "sha256": "46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f", + "provenance": { + "project": "ShareX native executable component", + "version": "19.0.2", + "upstreamFile": "ShareX-19.0.2-portable.zip", + "url": "https://github.com/ShareX/ShareX/releases/download/v19.0.2/ShareX-19.0.2-portable.zip", + "transport_sha256": "967b5d838df5ce25fb5fd7cbd16f1bd797427e2df16907b022b74a4bc0fb9d54", + "transport_bytes": 168796487, + "derivation": "selected regular ZIP member ShareX.exe; PE32+ x86-64 with no CLR directory; no target execution" + } + }, + { + "id": "freedos.edlin.exe", + "file": "EDLIN.EXE", + "bytes": 31492, + "sha256": "96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e", + "provenance": { + "project": "FreeDOS EDLIN", + "version": "2.24", + "upstreamFile": "edlin.zip", + "url": "https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/repositories/1.4/base/edlin.zip", + "transport_sha256": "9db8a0b50bc4be5c2358cd0fa1fb6fc77342c6d5175a6b1084547c370e428f8a", + "transport_bytes": 377009, + "derivation": "selected regular ZIP member BIN/EDLIN.EXE; original UPX-compressed DOS MZ; APPINFO/EDLIN.LSM reports version; no execution or unpacking" + } + }, { "id": "vscode.tree-sitter.wasm", "file": "tree-sitter.wasm", diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index 348bf02c2a..0bfd6da529 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -1,16 +1,14 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; -import { - mkdir, - open, - readFile, - readdir, - stat, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { + NATIVE_ARTIFACTS, + measureNativeEntry, + nativeEntrySeed, +} from "./native-entry.mjs"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Ajv2020 } from "ajv/dist/2020.js"; @@ -38,10 +36,15 @@ if ( !isAbsolute(values.path) || !isAbsolute(values.output) || !["complete", "summary"].includes(values.delivery) || - !["entry", "entry-and-main"].includes(values["native-question"]) || + !["entry", "entry-and-main", "entry-views"].includes( + values["native-question"], + ) || !["inventory", "selected"].includes(values["javascript-question"]) || !["default", "selected"].includes(values.schemas) || - (values.artifact !== "jq" && values["native-question"] !== "entry") || + (!NATIVE_ARTIFACTS.includes(values.artifact) && + values["native-question"] !== "entry") || + (values.artifact !== "jq" && + values["native-question"] === "entry-and-main") || (values.artifact !== "obsidian.asar" && values["javascript-question"] !== "inventory") ) @@ -118,6 +121,9 @@ await writeFile( ); const fingerprints = { measure_source_sha256: await fileSha(fileURLToPath(import.meta.url)), + native_workflow_source_sha256: await fileSha( + fileURLToPath(new URL("./native-entry.mjs", import.meta.url)), + ), runtime_files_sha256: sha(JSON.stringify(runtimeFiles)), runtime_dispatcher_sha256: await fileSha( resolve(values["runtime-root"], "scripts/rea.mjs"), @@ -545,111 +551,19 @@ const workflows = { evidence_ids: [info.evidence_id], }; }, - jq: async () => { - const file = await open(values.path, "r"); - let header; - try { - header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer; - } finally { - await file.close(); - } - if (header.subarray(0, 6).toString("hex") !== "7f454c460201") - throw new Error("Expected ELF64 little endian entry seed"); - const address = `0x${header.readBigUInt64LE(24).toString(16)}`; - await call("open_binary", { path: values.path, provider_id: "ghidra" }); - try { - const containing = await call("resolve_containing_procedure", { - address, - }); - const info = await call("analyze_function", { procedure: address }); - const n = normalized(info); - // This exact application entry is materialized in the pinned jq entry's - // observed MOV RDI, 0x401f00 instruction, not an original-source claim. - let main; - const mainEvidenceIds = []; - if (values["native-question"] === "entry-and-main") { - let sourceId; - try { - const completed = await call("analyze_function", { - procedure: "0x401f00", - }); - sourceId = completed.evidence_id; - } catch (error) { - const reference = - error instanceof ToolFailure && - error.value?.error?.code === "resource_constraint" - ? error.value.error.details?.reported_limits?.evidence_reference - : undefined; - if (reference?.kind !== "retained-evidence") throw error; - sourceId = reference.evidence_id; - } - mainEvidenceIds.push(sourceId); - main = {}; - for (const facet of [ - "procedure", - "pseudocode", - "referenced_strings", - "callees", - "callers", - "basic_blocks", - ]) { - let offset = 0; - const items = []; - const pages = []; - do { - const selected = await call("inspect_analysis_view", { - source: { kind: "retained-evidence", evidence_id: sourceId }, - view: { - kind: "native", - facet, - offset, - limit: - facet === "pseudocode" - ? 65536 - : facet === "basic_blocks" - ? 1 - : 64, - }, - }); - mainEvidenceIds.push(selected.evidence_id); - const v = normalized(selected); - items.push(v.item); - pages.push({ - coverage: v.coverage, - limitations: v.limitations, - unknowns: v.unknowns, - }); - if (v.coverage.exhausted || facet === "basic_blocks") break; - if ( - v.coverage.next_offset === null || - v.coverage.next_offset <= offset - ) - throw new Error("Native view did not advance"); - offset = v.coverage.next_offset; - } while (true); - main[facet] = { items, pages }; - } - } - return { - artifact_sha256: targetSha(info), - entry_address: address, - containing_procedure: normalized(containing), - procedure: n.procedure, - pseudocode_prefix: n.pseudocode.slice(0, 512), - caller_count: n.callers.length, - callee_count: n.callees.length, - ...(main === undefined ? {} : { main }), - analysis_profile: info.analysis_profile, - evidence_ids: [ - containing.evidence_id, - info.evidence_id, - ...mainEvidenceIds, - ], - }; - } finally { - await call("close_binary", {}); - } - }, + ...Object.fromEntries( + NATIVE_ARTIFACTS.map((artifact) => [ + artifact, + async () => + measureNativeEntry({ + seed: await nativeEntrySeed(artifact, values.path), + values, + call, + inputSha, + ToolFailure, + }), + ]), + ), }; try { @@ -740,7 +654,9 @@ const report = { artifact_sha256_after: finalInputSha, delivery: values.delivery, sdk_schema_mode: values.schemas, - native_question: values.artifact === "jq" ? values["native-question"] : null, + native_question: NATIVE_ARTIFACTS.includes(values.artifact) + ? values["native-question"] + : null, javascript_question: values.artifact === "obsidian.asar" ? values["javascript-question"] : null, provider_settings_sha256: sha(JSON.stringify(selectedProviders)), diff --git a/experiments/analysis-efficiency/native-entry-views.md b/experiments/analysis-efficiency/native-entry-views.md new file mode 100644 index 0000000000..1c02fbfd14 --- /dev/null +++ b/experiments/analysis-efficiency/native-entry-views.md @@ -0,0 +1,178 @@ +# Native PE and DOS entry-view checkpoint + +Continuation of [#2016](https://github.com/morluto/rea/issues/2016) after +#2122 and #2193 merged. Real Ghidra observations and real Codex CLI answers +support the selected native facets on these shipped inputs. They do not justify +an automatic native summary policy: the four selected replies are larger than +the complete dossier for the small PE entry, and smaller for the DOS entry. +The initial complete analysis still runs and is delivered in both cases. + +This checkpoint changes experiment scripts, corpus inputs and grading only. +Existing production CLI/MCP contracts, defaults and session behavior are unchanged. + +## Inputs and runtime + +Production runtime: main `10400fdb57e4226c5ce15af17968a664f35498ce`, +including the merged WASM summary work and subsequent main changes. Node +24.18.0/npm 11.16.0, locked dependencies, actual Ghidra 12.1.4 on Linux. +Default auto-analysis remains enabled, with a 768 MiB JVM ceiling and one +active processor. The whole process family uses the existing one-CPU, +4 GiB hard/3 GiB early-stop, no-swap, bounded-task resource scope. + +| Shipped artifact | Selected bytes | SHA-256 | Static scope | +| ---------------------------------- | -------------: | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| ShareX 19.0.2 `ShareX.exe` | 198,656 | `46af1ccc514b395cb703633abb5a505a771942b7db9b1915f31aeea541b3685f` | Native PE32+ x86-64 component with no CLR directory; entry `0x1400125c0` derived from original image base/RVA | +| FreeDOS EDLIN 2.24 `BIN/EDLIN.EXE` | 31,492 | `96b2b6d543e2d8bdc83e313756444e103339eb5ee2c1229abe3607539b31ad6e` | Original UPX-compressed DOS MZ; admitted entry `0x10000`, independently verified static load image | + +Acquisition URLs, archive sizes/digests and exact regular ZIP members are in +[corpus.json](corpus.json). EDLIN's package `APPINFO/EDLIN.LSM` reports its +version and UPX compression. No binary was executed, unpacked or committed. +The independent PE header does not establish Windows runtime behavior. +DOS load-image verification and unpacking-stub pseudocode do not recover the +editor's application logic or establish DOS execution. DOS COM is still untested. + +The SDK and frozen model runtime share the exact `dist/` and `bridge/` file +table digest: +`e2b67eb0966072fd8b159edfab074cbaf1db9bfc0ac9c57d25ba1435cc0fdbcf`. +The model copy was independently checked against all 1,375 compiled files and +30 bridge files. Runtime copies and detailed receipts remain outside Git. + +## Public SDK observations + +The workflow opens the selected target with Ghidra, resolves the entry, +calls `analyze_function` once, reads retained procedure/pseudocode/callers/callees +facets, and closes the ephemeral session. DOS obtains its address from +`inspect_native_load_image` before resolving the procedure. Each facet is checked +against the complete producer facts, original parent ID, target digest and distinct +derived Evidence ID. The actual SDK validates named advertised output contracts. + +| Entry | Complete dossier JSON bytes | Four facet replies combined | Workflow seconds | First provider query seconds | Last-observed family CPU seconds | Sampled family peak MiB | +| ------------ | --------------------------: | --------------------------: | ---------------: | ---------------------------: | -------------------------------: | ----------------------: | +| ShareX PE | 50,936 | 68,257 | 63.123 | 57.490 | 63.660 | 1,197.85 | +| EDLIN DOS MZ | 224,670 | 70,539 | 26.436 | 21.366 | 26.899 | 939.25 | + +These are exploratory observations, not a paired implementation performance +cohort. JSON byte counts include the SDK tool-result representations, excluding +catalog discovery and wire framing; they are not model token counts. Four PE +facets cost about 34% more bytes than that dossier. The DOS facets cost about +69% fewer bytes **when reading already-retained Evidence**. Their first analysis +still returned the full dossier; summing both paths increases first-task traffic. +The first provider query includes the observable wait for the imported Program +and its analysis, not an independently measured engine-only stage. + +The observed PE entry contains 18 analyzed bytes, zero recorded callers and two +resolved callees. Ghidra's terminal-call caveat remains explicit. The DOS entry +contains 53 analyzed bytes and zero recorded callers/callees; overlapping +instruction and abnormal stack-frame warnings remain in the pseudocode. +Neither zero count establishes absence of unresolved runtime calls. + +An earlier PE probe completed production analysis and two facets, then failed +its harness assertion: a pseudocode page is `{text, unit}`, not a string. That +failed attempt remains archived and is excluded from successful workflow figures. +The corrected exploratory SDK driver fingerprints are +`4876c0ecaa84069ddc508e29649715c6c914bd3c4960a76fa9724b5992123699` +and native-helper `76023ad45cd1edab57dcd4a8ccf29a9b8a8378029ff2d9711ae35c73940e80d6`. +The published helper in `6d7f6885c` removes redundant scopes; replaying the actual +PE/DOS replies reproduces every answer field and the same 9/10 calls. Recorded +real jq entry and entry/main replies also preserve their selected facts, profile +and 4/12-call ordering. These replays are driver regression checks, not fresh +provider runs or timing measurements. + +## Real Codex CLI observations + +Three serial runs used Codex CLI 0.161.0, requested alias `gpt-5.6-terra`, high +reasoning, temporary isolated profiles and the frozen modified REA MCP runtime. +Resolved server-side model identity is not inferred. The same explicit seven-tool +subset was enabled: open, containing-procedure resolution, dossier, analysis view, +load image, retained Evidence discovery and close. This is not full-catalog tool +selection verification. Questions are [PE](questions/native-pe.txt) and +[DOS](questions/native-dos.txt). + +| Trial | Total input | Cached input | Uncached input | Output | Seconds | Calls / failed | +| ----- | ----------: | -----------: | -------------: | -----: | ------: | -------------: | +| PE 1 | 451,337 | 399,616 | 51,721 | 3,820 | 130.073 | 12 / 2 | +| DOS 1 | 730,578 | 658,944 | 71,634 | 3,310 | 134.435 | 9 / 0 | +| PE 2 | 498,322 | 429,056 | 69,266 | 3,038 | 192.373 | 9 / 1 | + +All three answers match the selected original digest, address, procedure +classification/signature/locals, inclusive body ranges, body byte count, +pseudocode prefix and relationship counts. Actual Evidence references were +observed in the responses; target bytes remained unchanged, close completed and +the copied profiles were removed. DOS additionally matches the verified load +status and every supplied load-image metadata field. Manual review preserves +static/packed-target and terminal-flow qualifications. + +The first grader expected only particular nested JSON shapes. It rejected PE's +equivalent text-page object/inclusive-range label and could not grade DOS's +explicit `entry @ 0x10000` display. The updated grader compares exact source +values for those representations without parsing displays into lookup identities. +It still rejects wrong text units, returned lengths, body endpoints and missing +facts. Original answers and first grading attempts remain archived; regrading +does not repeat model calls or change their tokens/times. + +The PE agent hit the advertised-but-unimplemented native summary recipe: both +trials attempted `view: {kind: "summary"}`; the first also tried the invalid +native facet `summary`. Those failed calls and all subsequent recovery/repeated +reads remain included. The public SDK independently reproduced `invalid_request` +for native summary on both inputs. This pre-existing contract-description mismatch +is recorded in `/tmp/rea-analysis-efficiency-bugs-20261012.md` for team repair, +as requested; this branch does not repair it. + +Model sampled peaks were 1,318.74 / 1,017.70 / 1,234.92 MiB, with last-observed +family CPU 72.956 / 71.534 / 74.089 seconds. SDK/model scopes did not abort and +their last-observed limit-event counters were zero. Final cgroup counters remained +unavailable after scope teardown: the strict resource-qualified overall grades +remain unverified. The selected fact/lifecycle checks pass; no universal latency, +OOM absence or CPU/memory saving is inferred from these samples. + +## Reproduction and next decision + +Use the pinned toolchain and build the selected production revision. Keep its +runtime copy, matching dependencies, provider settings, binaries and receipts +outside the checkout, as described in [README.md](README.md). + +```sh +python3 experiments/analysis-efficiency/resource-run.py -- \ + node experiments/analysis-efficiency/measure.mjs \ + --runtime-root /absolute/frozen-runtime --revision EXACT_PRODUCTION_SHA \ + --artifact sharex.exe --path /absolute/ShareX.exe \ + --providers /absolute/explicit-providers.json \ + --native-question entry-views --schemas selected \ + --output /absolute/new-native-measurement +``` + +For DOS select `--artifact freedos.edlin.exe` and the original extracted +`EDLIN.EXE`. `--native-question entry` retains the existing compact experimental +answer; `entry-views` performs additional verification reads and the explicit +summary refusal probe. It does not select summary delivery from `analyze_function`. +The helper fingerprint participates in comparison, and missing native helper +fingerprints are not treated as a comparable current driver. + +The reviewed `entry-views` answer separates selected facet facts from run-specific +references: `evidence_ids.retained_views` retains each facet's original view and +parent ID, while `retained_views` holds its item, coverage, limitations and unknowns. +DOS load-image facts retain their source ID and must match the selected carrier +digest before their entry address can be used; a stale result closes the session +without querying a procedure. +Derived view IDs are checked against every prior producer reference as well as +other derived views. DOS model grades require the actual completed load-image +response and its reported source ID; status, digest, entry and supplied metadata +are checked against that observation. Missing, failed or unreferenced responses +cannot establish load-image verification. +The comparator preserves both runs' reference maps and compares the facts. Existing +`entry`/`entry-and-main` answers keep their ID arrays. Historical SDK receipts above +retain their original pre-review representation; no replies or measurements were +rewritten, and no provider/model runs were repeated for this representation fix. + +Relevant validation includes the real workflows above, recorded-reply regression, +grader negative probes, JavaScript/Python syntax, formatting/diff checks and +`npm run check:fast`. The cold type/lint gate passed in 112.602 seconds with +sampled peak 3,220,914,176 bytes and five last-observed `memory.high` events; +this gate is not a performance-qualified experiment. Unrelated domain suites, +packaging and earlier model cohorts were not rerun for this experiment-only change. + +The next useful comparison is question-specific native primitives versus a +complete dossier, with identical requested facts and actual model trials. +Larger PE functions and another unpacked DOS/COM application can extend the +coverage progressively. Preserve existing complete defaults and solve the +summary-description bug separately before recommending a native summary route. diff --git a/experiments/analysis-efficiency/native-entry.mjs b/experiments/analysis-efficiency/native-entry.mjs new file mode 100644 index 0000000000..84bc934ff6 --- /dev/null +++ b/experiments/analysis-efficiency/native-entry.mjs @@ -0,0 +1,272 @@ +import { open, readFile } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; + +export const NATIVE_ARTIFACTS = ["jq", "sharex.exe", "freedos.edlin.exe"]; +const normalized = (value) => value.normalized_result; + +const retainedEntryViews = async ( + call, + info, + inputSha, + ToolFailure, + sourceEvidenceIds, +) => { + const n = normalized(info); + const retainedViews = []; + const retainedViewEvidence = []; + const observedEvidenceIds = new Set([info.evidence_id, ...sourceEvidenceIds]); + let nativeSummaryOutcome, summaryEvidenceId; + for (const facet of ["procedure", "pseudocode", "callers", "callees"]) { + const selected = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: info.evidence_id }, + view: { + kind: "native", + facet, + offset: 0, + limit: facet === "pseudocode" ? 512 : 64, + }, + }); + const view = normalized(selected); + if (observedEvidenceIds.has(selected.evidence_id)) + throw new Error("Retained native view reused an Evidence identity"); + const expected = + facet === "procedure" + ? n.procedure + : facet === "pseudocode" + ? { text: n.pseudocode.slice(0, 512), unit: "utf16-code-units" } + : n[facet].slice(0, 64); + if ( + !isDeepStrictEqual(view.item, expected) || + view.parent_evidence_id !== info.evidence_id || + view.artifact.sha256 !== inputSha + ) + throw new Error( + "Retained native view changed the selected source facts or identity", + ); + observedEvidenceIds.add(selected.evidence_id); + retainedViews.push({ + facet, + item: view.item, + coverage: view.coverage, + limitations: view.limitations, + unknowns: view.unknowns, + }); + retainedViewEvidence.push({ + facet, + evidence_id: selected.evidence_id, + parent_evidence_id: view.parent_evidence_id, + }); + } + // Exercise the advertised summary recipe separately. A known refusal + // is recorded as such; it does not validate a nonexistent capability. + try { + const summary = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: info.evidence_id }, + view: { kind: "summary" }, + }); + if (observedEvidenceIds.has(summary.evidence_id)) + throw new Error("Native summary reused an Evidence identity"); + nativeSummaryOutcome = { status: "supported" }; + summaryEvidenceId = summary.evidence_id; + } catch (error) { + if ( + !(error instanceof ToolFailure) || + error.value?.error?.code !== "invalid_request" + ) + throw error; + nativeSummaryOutcome = { + status: "unsupported", + error: error.value.error, + }; + } + return { + retainedViews, + retainedViewEvidence, + nativeSummaryOutcome, + summaryEvidenceId, + }; +}; + +const retainedJqMain = async (call, ToolFailure) => { + // This exact application entry is materialized in the pinned jq entry's + // observed MOV RDI, 0x401f00 instruction, not an original-source claim. + let main; + const mainEvidenceIds = []; + let sourceId; + try { + const completed = await call("analyze_function", { + procedure: "0x401f00", + }); + sourceId = completed.evidence_id; + } catch (error) { + const reference = + error instanceof ToolFailure && + error.value?.error?.code === "resource_constraint" + ? error.value.error.details?.reported_limits?.evidence_reference + : undefined; + if (reference?.kind !== "retained-evidence") throw error; + sourceId = reference.evidence_id; + } + mainEvidenceIds.push(sourceId); + main = {}; + for (const facet of [ + "procedure", + "pseudocode", + "referenced_strings", + "callees", + "callers", + "basic_blocks", + ]) { + let offset = 0; + const items = []; + const pages = []; + do { + const selected = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: sourceId }, + view: { + kind: "native", + facet, + offset, + limit: + facet === "pseudocode" ? 65536 : facet === "basic_blocks" ? 1 : 64, + }, + }); + mainEvidenceIds.push(selected.evidence_id); + const v = normalized(selected); + items.push(v.item); + pages.push({ + coverage: v.coverage, + limitations: v.limitations, + unknowns: v.unknowns, + }); + if (v.coverage.exhausted || facet === "basic_blocks") break; + if (v.coverage.next_offset === null || v.coverage.next_offset <= offset) + throw new Error("Native view did not advance"); + offset = v.coverage.next_offset; + } while (true); + main[facet] = { items, pages }; + } + return { main, mainEvidenceIds }; +}; + +export const measureNativeEntry = async ({ + seed, + values, + call, + inputSha, + ToolFailure, +}) => { + await call("open_binary", { path: values.path, provider_id: "ghidra" }); + try { + // DOS addresses follow the actual admitted load image. A file header + // offset is not a provider address or proof of runtime execution. + let loadImage, loadImageEvidenceId; + if (seed === undefined) { + const imageEvidence = await call("inspect_native_load_image", {}); + loadImage = normalized(imageEvidence); + if (loadImage.status !== "verified") + throw new Error( + "The shipped DOS load image was not independently verified", + ); + if (loadImage.target_sha256 !== inputSha) + throw new Error("The DOS load image belongs to a different target"); + loadImageEvidenceId = imageEvidence.evidence_id; + seed = loadImage.entry.linear_address; + } + const address = seed; + const containing = await call("resolve_containing_procedure", { + address, + }); + const info = await call("analyze_function", { procedure: address }); + const n = normalized(info); + const selected = + values["native-question"] === "entry-views" + ? await retainedEntryViews(call, info, inputSha, ToolFailure, [ + containing.evidence_id, + ...(loadImageEvidenceId === undefined ? [] : [loadImageEvidenceId]), + ]) + : {}; + const { main, mainEvidenceIds = [] } = + values["native-question"] === "entry-and-main" + ? await retainedJqMain(call, ToolFailure) + : {}; + return { + artifact_sha256: info.subject?.digest.sha256, + entry_address: address, + containing_procedure: normalized(containing), + procedure: n.procedure, + pseudocode_prefix: n.pseudocode.slice(0, 512), + caller_count: n.callers.length, + callee_count: n.callees.length, + ...(main === undefined ? {} : { main }), + analysis_profile: info.analysis_profile, + ...(loadImage === undefined ? {} : { load_image: loadImage }), + ...(selected.retainedViews === undefined + ? {} + : { + retained_views: selected.retainedViews, + native_summary_outcome: selected.nativeSummaryOutcome, + }), + evidence_ids: + selected.retainedViews === undefined + ? [ + containing.evidence_id, + info.evidence_id, + ...mainEvidenceIds, + ...(loadImageEvidenceId === undefined + ? [] + : [loadImageEvidenceId]), + ] + : { + containing_procedure: containing.evidence_id, + function_dossier: info.evidence_id, + ...(loadImageEvidenceId === undefined + ? {} + : { native_load_image: loadImageEvidenceId }), + retained_views: selected.retainedViewEvidence, + ...(selected.summaryEvidenceId === undefined + ? {} + : { native_summary: selected.summaryEvidenceId }), + }, + }; + } finally { + await call("close_binary", {}); + } +}; + +export const nativeEntrySeed = async (artifact, path) => { + if (artifact === "jq") { + const file = await open(path, "r"); + let header; + try { + header = (await file.read(Buffer.alloc(32), 0, 32, 0)).buffer; + } finally { + await file.close(); + } + if (header.subarray(0, 6).toString("hex") !== "7f454c460201") + throw new Error("Expected ELF64 little endian entry seed"); + return `0x${header.readBigUInt64LE(24).toString(16)}`; + } + if (artifact === "sharex.exe") { + const bytes = await readFile(path); + const pe = bytes.readUInt32LE(0x3c); + const optional = pe + 24; + if ( + bytes.subarray(pe, pe + 4).toString("hex") !== "50450000" || + bytes.readUInt16LE(pe + 4) !== 0x8664 || + bytes.readUInt16LE(optional) !== 0x20b || + bytes.readUInt32LE(optional + 108) <= 14 || + bytes.readUInt32LE(optional + 112 + 14 * 8) !== 0 || + bytes.readUInt32LE(optional + 116 + 14 * 8) !== 0 + ) + throw new Error( + "Expected the pinned native PE32+ entry seed without CLR metadata", + ); + const address = + bytes.readBigUInt64LE(optional + 24) + + BigInt(bytes.readUInt32LE(optional + 16)); + return `0x${address.toString(16)}`; + } + if (artifact === "freedos.edlin.exe") return undefined; + throw new Error("Select a pinned native artifact"); +}; diff --git a/experiments/analysis-efficiency/questions/native-dos.txt b/experiments/analysis-efficiency/questions/native-dos.txt new file mode 100644 index 0000000000..48c52a9ec7 --- /dev/null +++ b/experiments/analysis-efficiency/questions/native-dos.txt @@ -0,0 +1 @@ +Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. This is the original UPX-compressed FreeDOS EDLIN 2.24 DOS MZ executable; inspect its admitted load image and entry stub, without running or unpacking it. Keep default auto-analysis enabled. Open the target with provider_id ghidra, call inspect_native_load_image, and use its verified entry.linear_address as the seed. Resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve missing/partial facts and terminal-flow caveats. Verified static import and unpacking-stub pseudocode do not prove DOS execution or recovery of the editor's application logic. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, load_image_status, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. Stop when these facts and cleanup are supported; report unsupported outcomes explicitly. diff --git a/experiments/analysis-efficiency/questions/native-pe.txt b/experiments/analysis-efficiency/questions/native-pe.txt new file mode 100644 index 0000000000..749e962003 --- /dev/null +++ b/experiments/analysis-efficiency/questions/native-pe.txt @@ -0,0 +1 @@ +Statically inspect {{artifact_path}} using only the registered REA tools and Ghidra. The independently read, pinned PE32+ header reports image base 0x140000000 and entry RVA 0x125c0, so use address 0x1400125c0 as the analyst seed. This is a native x86-64 Windows PE component with no CLR directory, inspected on Linux; do not claim Windows runtime behavior. Keep default auto-analysis enabled. Open the target with provider_id ghidra, resolve the containing procedure, analyze that procedure once, and close the ephemeral session after obtaining the facts. If a result is clipped, use retained native facets without reanalysis. Report exact target SHA-256, entry_address, containing_procedure, complete procedure metadata including classification, signature, locals and inclusive body ranges, the first 512 UTF-16 units of pseudocode, caller_count, callee_count, real Evidence IDs and limitations. Preserve terminal-flow caveats and unresolved static edges; recovered pseudocode is not original source. Do not execute targets, launch a GUI, or use shell/file tools/network or other agents. Final JSON fields: artifact_sha256, entry_address, containing_procedure, procedure, pseudocode_prefix, caller_count, callee_count, evidence_ids, limitations. The seed applies only to the pinned ShareX.exe in corpus.json. Stop when these facts and cleanup are supported. diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py index 22d6dd7668..c31a012902 100644 --- a/experiments/analysis-efficiency/review-model.py +++ b/experiments/analysis-efficiency/review-model.py @@ -182,18 +182,61 @@ def main(): } for row in rows], key=lambda row: row['node_id']) check('all_selected_module_identities', select(answer.get('modules', [])), select(reference['modules'])) elif domain == 'native': - for key in ['entry_address', 'pseudocode_prefix', 'caller_count', 'callee_count']: + for key in ['entry_address', 'caller_count', 'callee_count']: check(key, answer.get(key), reference[key]) - containing = answer['containing_procedure'] + prefix = answer.get('pseudocode_prefix') + if isinstance(prefix, dict) and prefix.get('unit') == 'utf16-code-units': + text = prefix.get('text') + if 'requested' in prefix: + check('pseudocode_prefix.requested', prefix['requested'], 512) + if 'returned' in prefix: + check('pseudocode_prefix.returned', prefix['returned'], len(reference['pseudocode_prefix'].encode('utf-16-le')) // 2) + prefix = text + check('pseudocode_prefix', prefix, reference['pseudocode_prefix']) + containing = answer.get('containing_procedure') expected_containing = reference['containing_procedure']['procedure'] - for key in ['address', 'name']: - check('containing_procedure.' + key, containing.get(key, containing.get('procedure', {}).get(key)), expected_containing[key]) - procedure = answer['procedure'] + if isinstance(containing, str): + # Compare the explicitly reported display to the known source; + # never parse a display name into a canonical lookup identity. + check('containing_procedure.display', containing, + expected_containing['name'] + ' @ ' + expected_containing['address']) + else: + containing = containing if isinstance(containing, dict) else {} + nested = containing.get('procedure') + nested = nested if isinstance(nested, dict) else {} + for key in ['address', 'name']: + check('containing_procedure.' + key, containing.get(key, nested.get(key)), expected_containing[key]) + procedure = answer.get('procedure') + procedure = procedure if isinstance(procedure, dict) else {} expected = reference['procedure'] for key in ['address', 'name', 'classification', 'signature', 'locals']: check('procedure.' + key, procedure.get(key), expected[key]) - check('procedure.body_ranges', procedure.get('body_ranges', procedure.get('body', {}).get('ranges')), expected['body']['ranges']) - check('procedure.total_bytes', procedure.get('total_bytes', procedure.get('body', {}).get('total_bytes')), expected['body']['total_bytes']) + body = procedure.get('body') + body = body if isinstance(body, dict) else {} + check('procedure.body_ranges', procedure.get('body_ranges', body.get('ranges', body.get('ranges_inclusive'))), expected['body']['ranges']) + check('procedure.total_bytes', procedure.get('total_bytes', body.get('total_bytes')), expected['body']['total_bytes']) + if 'load_image' in reference: + sources = [source for source in producer_results.get('inspect_native_load_image', []) + if isinstance(source, dict) + and source.get('operation') == 'inspect_native_load_image' + and isinstance(source.get('evidence_id'), str) + and source.get('evidence_id') in reported_ids + and isinstance(source.get('normalized_result'), dict)] + checks['load_image_source_observed_and_referenced'] = bool(sources) + load = sources[-1]['normalized_result'] if sources else {} + entry = load.get('entry') + entry = entry if isinstance(entry, dict) else {} + check('load_image.target_sha256', load.get('target_sha256'), reference['artifact_sha256']) + check('load_image.source_status', load.get('status'), reference['load_image']['status']) + check('load_image.source_entry', entry.get('linear_address'), reference['entry_address']) + status = answer.get('load_image_status') + check('load_image_status', status.get('status') if isinstance(status, dict) else status, load.get('status')) + if isinstance(status, dict): + for key in ['format', 'load_segment', 'header_bytes', 'module_bytes', 'overlay_bytes']: + if key in status: + check('load_image.' + key, status[key], load.get(key)) + if 'entry_linear_address' in status: + check('load_image.entry_linear_address', status['entry_linear_address'], entry.get('linear_address')) calls = summary['completed_mcp_calls'] checks['default_analysis_not_disabled'] = all(not call['arguments'].get('skip_auto_analysis', False) for call in calls if call['tool'] == 'open_binary') checks['session_close_completed'] = any(call['tool'] == 'close_binary' and call['status'] == 'completed' for call in calls) @@ -248,6 +291,7 @@ def main(): and events['memory.events'].get('high') == 0 ) report = { + 'review_source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 'domain': domain, 'checks': checks, 'all_selected_checks_pass': all(checks.values()), 'differences': differences, 'tool_errors': tool_errors, 'summary': summary, 'fingerprints': {key: value for key, value in settings.items() if 'sha256' in key or key in ['runtime_revision', 'enabled_tools', 'authority']}, diff --git a/tests/boundary/cli/nativeEfficiencyComparison.test.ts b/tests/boundary/cli/nativeEfficiencyComparison.test.ts new file mode 100644 index 0000000000..65542f0ea2 --- /dev/null +++ b/tests/boundary/cli/nativeEfficiencyComparison.test.ts @@ -0,0 +1,336 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); + +// A recording port checks the experimental producer/consumer representation. +// Real provider verification remains in the separate native experiment receipts. +const generate = ` +import {measureNativeEntry} from './experiments/analysis-efficiency/native-entry.mjs'; +const sha='0'.repeat(64); +const duplicate=process.argv[1]==='duplicate'; +const stale=process.argv[1]==='stale-dos'; +const collision=process.argv[1]; +const dos=process.argv[1]==='dos'||stale||collision==='load-collision'; +const dossier={procedure:{address:'0x1000'},pseudocode:'return;',callers:[],callees:[]}; +class ToolFailure extends Error {} +const build=async digit=>{ + const parent='ev_'+digit.repeat(63)+'0'; + let ordinal=0; + const call=async(name,args)=>{ + if(stale)process.stdout.write(name+'\\n'); + if(name==='open_binary'||name==='close_binary')return {}; + if(name==='inspect_native_load_image')return {evidence_id:'ev_'+digit.repeat(63)+'6',normalized_result:{status:'verified',target_sha256:stale?'f'.repeat(64):sha,entry:{linear_address:'0x1000'}}}; + if(name==='resolve_containing_procedure')return {evidence_id:'ev_'+digit.repeat(63)+'7',normalized_result:{found:true}}; + if(name==='analyze_function')return {evidence_id:parent,subject:{digest:{sha256:sha}},normalized_result:dossier}; + const id='ev_'+digit.repeat(63)+(collision==='containing-collision'?7:collision==='load-collision'?6:duplicate?1:++ordinal); + if(args.view.kind==='summary')return {evidence_id:id}; + const facet=args.view.facet; + const item=facet==='pseudocode'?{text:dossier.pseudocode,unit:'utf16-code-units'}:dossier[facet]; + return {evidence_id:id,normalized_result:{parent_evidence_id:parent,artifact:{sha256:sha},item,coverage:{exhausted:true},limitations:[],unknowns:[]}}; + }; + return measureNativeEntry({seed:dos?undefined:'0x1000',values:{path:'/selected/input','native-question':'entry-views'},call,inputSha:sha,ToolFailure}); +}; +process.stdout.write(JSON.stringify([await build('1'),await build('2')])); +`; + +it.each([ + "observed", + "missing", + "errored", + "wrong-target", + "wrong-entry", + "unreferenced", +])( + "binds the DOS model grade to its observed load image: %s", + async (scenario) => { + // Synthetic transcripts exercise grading; they are not real-provider or + // resource measurements. Real DOS transcripts are replayed separately. + const root = await createTestTempDirectory("rea-dos-grader-"); + const trial = join(root, "trial"); + const referenceDirectory = join(root, "reference"); + await mkdir(trial); + await mkdir(referenceDirectory); + const sha = "0".repeat(64); + const imageId = "ev_" + "1".repeat(64); + const procedureId = "ev_" + "2".repeat(64); + const procedure = { + address: "0x1000", + name: "entry", + classification: "observed", + signature: null, + locals: [], + body: { ranges: [], total_bytes: 0 }, + }; + const loadImage = { + status: "verified", + target_sha256: sha, + entry: { linear_address: "0x1000" }, + }; + const reference = { + artifact_sha256: sha, + entry_address: "0x1000", + caller_count: 0, + callee_count: 0, + pseudocode_prefix: "return;", + containing_procedure: { procedure }, + procedure, + load_image: loadImage, + }; + const answer = { + ...reference, + load_image_status: "verified", + evidence_ids: [ + procedureId, + ...(["missing", "errored", "unreferenced"].includes(scenario) + ? [] + : [imageId]), + ], + }; + const event = (tool: string, status: string, value: unknown) => ({ + type: "item.completed", + item: { + type: "mcp_tool_call", + tool, + status, + arguments: {}, + result: { structured_content: value }, + }, + }); + const events = [ + event("analyze_function", "completed", { evidence_id: procedureId }), + ]; + if (scenario !== "missing") + events.push( + event( + "inspect_native_load_image", + scenario === "errored" ? "failed" : "completed", + scenario === "errored" + ? { error: { code: "unsupported_target" } } + : { + operation: "inspect_native_load_image", + evidence_id: imageId, + normalized_result: { + ...loadImage, + target_sha256: + scenario === "wrong-target" ? "f".repeat(64) : sha, + entry: { + linear_address: + scenario === "wrong-entry" ? "0x2000" : "0x1000", + }, + }, + }, + ), + ); + const resourceLog = join(root, "resource.log"); + const output = join(root, "review.json"); + for (const [path, value] of [ + [ + join(trial, "summary.json"), + { + completed_mcp_calls: [ + { tool: "close_binary", status: "completed", arguments: {} }, + ], + }, + ], + [join(trial, "trial-settings.json"), {}], + [join(trial, "cleanup.json"), { profile_removed: true }], + [join(trial, "answer.txt"), answer], + [join(referenceDirectory, "answer.json"), reference], + [ + resourceLog, + { + resource_scope: "synthetic-grader-fixture", + abort: null, + resource_counters_verified: true, + resource_events_final: { + "memory.events": { high: 0, max: 0, oom: 0, oom_kill: 0 }, + "pids.events": { max: 0 }, + }, + }, + ], + ] as const) + await writeFile(path, JSON.stringify(value)); + await writeFile( + join(trial, "events.jsonl"), + events.map((value) => JSON.stringify(value)).join("\n"), + ); + const command = execute("python3", [ + resolve("experiments/analysis-efficiency/review-model.py"), + "--domain", + "native", + "--trial", + trial, + "--reference", + referenceDirectory, + "--resource-log", + resourceLog, + "--output", + output, + ]); + if (scenario === "observed") await command; + else await expect(command).rejects.toMatchObject({ code: 1 }); + const report = JSON.parse(await readFile(output, "utf8")); + expect(report.all_selected_checks_pass).toBe(scenario === "observed"); + if (["missing", "errored", "unreferenced"].includes(scenario)) + expect(report.checks.load_image_source_observed_and_referenced).toBe( + false, + ); + if (scenario === "wrong-target") + expect(report.checks["load_image.target_sha256"]).toBe(false); + if (scenario === "wrong-entry") + expect(report.checks["load_image.source_entry"]).toBe(false); + }, +); + +it("retains the DOS load-image source for its facts and derived entry seed", async () => { + const { stdout } = await execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "dos", + ]); + const [answer] = JSON.parse(stdout); + expect(answer).toMatchObject({ + entry_address: "0x1000", + load_image: { status: "verified", entry: { linear_address: "0x1000" } }, + evidence_ids: { native_load_image: "ev_" + "1".repeat(63) + "6" }, + }); +}); + +it("rejects a stale DOS load image before using its entry and closes the session", async () => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "stale-dos", + ]), + ).rejects.toMatchObject({ + stdout: "open_binary\ninspect_native_load_image\nclose_binary\n", + stderr: expect.stringContaining( + "The DOS load image belongs to a different target", + ), + }); +}); + +it.each(["containing-collision", "load-collision"])( + "rejects a retained view that reuses an earlier producer identity: %s", + async (collision) => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + collision, + ]), + ).rejects.toMatchObject({ + stderr: expect.stringContaining( + "Retained native view reused an Evidence identity", + ), + }); + }, +); + +it("rejects a producer that reuses a view identity across facets", async () => { + await expect( + execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + "duplicate", + ]), + ).rejects.toMatchObject({ + stderr: expect.stringContaining( + "Retained native view reused an Evidence identity", + ), + }); +}); + +it.each(["references", "item", "coverage", "limitations", "unknowns"])( + "separates native references while comparing %s exactly", + async (change) => { + const { stdout: generated } = await execute(process.execPath, [ + "--input-type=module", + "-e", + generate, + ]); + const [leftAnswer, rightAnswer] = JSON.parse(generated); + if (change === "item") + rightAnswer.retained_views[0].item.address = "0x1001"; + if (change === "coverage") + rightAnswer.retained_views[0].coverage.exhausted = false; + if (change === "limitations") + rightAnswer.retained_views[0].limitations.push("partial"); + if (change === "unknowns") + rightAnswer.retained_views[0].unknowns.push("unresolved"); + + const root = await createTestTempDirectory("rea-native-comparison-"); + const left = join(root, "left"); + const right = join(root, "right"); + const summary = { + artifact: "sharex.exe", + native_question: "entry-views", + provider_search_path_sha256: "0".repeat(64), + native_workflow_source_sha256: "1".repeat(64), + calls: [], + failure: null, + }; + for (const [directory, answer] of [ + [left, leftAnswer], + [right, rightAnswer], + ]) { + await mkdir(directory); + await writeFile(join(directory, "summary.json"), JSON.stringify(summary)); + await writeFile(join(directory, "answer.json"), JSON.stringify(answer)); + } + const output = join(root, "comparison.json"); + const command = execute(process.execPath, [ + resolve("experiments/analysis-efficiency/compare.mjs"), + "--left", + left, + "--right", + right, + "--output", + output, + ]); + if (change !== "references") { + await expect(command).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining('"retained_views"'), + }); + return; + } + const { stdout } = await command; + expect(JSON.parse(stdout)).toMatchObject({ + settings_match: true, + facts_match: true, + preserved_run_specific_values: [ + { + side: "left", + path: ["evidence_ids"], + value: leftAnswer.evidence_ids, + }, + { + side: "right", + path: ["evidence_ids"], + value: rightAnswer.evidence_ids, + }, + ], + }); + expect(leftAnswer.evidence_ids).not.toEqual(rightAnswer.evidence_ids); + expect(leftAnswer.evidence_ids.retained_views[0]).toMatchObject({ + evidence_id: "ev_" + "1".repeat(64), + parent_evidence_id: "ev_" + "1".repeat(63) + "0", + }); + expect(leftAnswer.evidence_ids.native_summary).toBe( + "ev_" + "1".repeat(63) + "5", + ); + }, +); From 0baad58e606f12a8dc6cef06253f85350378e5b1 Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 00:11:35 +0800 Subject: [PATCH 14/93] feat(ghidra): admit RISC-V ELF analysis through a pinned generic lane (#2151) * feat(ghidra): admit RISC-V ELF analysis through a pinned generic lane Refs #2063. Recognize `EM_RISCV` (243) as a provider-neutral family that carries its own ELF facts, and bound the first Ghidra lane to little-endian `ET_EXEC` images that declare the double-float ABI, pinned to `RISCV:LE:32:default` or `RISCV:LE:64:default` with the `gcc` compiler specification. Ghidra 12.1.4 is not reliable without the pin: its `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the 32-bit constraint with the comment that the loader "must choose between 'default' and 'AndeStar' variants". Measured on the installed release tree, a source-owned generic RV32 ELF with no Andes declaration imports as `RISCV:LE:32:AndeStar_v5:gcc`, while RV64 resolves to `RISCV:LE:64:default:gcc`. The lane therefore states the language per width and refuses any caller-selected processor outside it instead of inheriting the header guess. Refusals name the declaration rather than falling back to a guessed profile: big-endian images (Ghidra 12.1.4 ships no big-endian RISC-V language), non-`ET_EXEC` images, `EF_RISCV_RVE`, `EF_RISCV_TSO`, and every float ABI other than double, which the pinned generic compiler specifications model. The raw e_flags bits stay in Evidence as declarations, not as instruction semantics. Family recognition now has one owner for the ELF-metadata families: `ELF_METADATA_ARCHITECTURES`, `isElfMetadataArchitecture`, and `HeaderArchitecture` replace ten `Exclude` sites that would each have needed a second hand-edited member. `ghidraPinnedLanguage` is that owner for the import commitment: the session readback previously re-derived only the MIPS pin, so a profile committing any second pinned language failed with a language-mismatch error before the bridge started. Hopper and IDA refuse RISC-V by name, matching the MIPS wording. Caller-supplied raw-binary entry keeps its existing four-family list; this lane is ELF-header admission only. Verification on macOS 15.7.7 arm64, Node 24.18.0, Ghidra 12.1.4 PUBLIC and JDK 21.0.12.1, with the host decompiler built from the release sources for `os/mac_arm_64`: `npm run check` passes; `npm run test:fast` passes 396 test files; `npm run verify:test-discovery` discovers all 917 test files exactly once. Source-owned RV32 and RV64 double-float `ET_EXEC` fixtures complete `rea analyze --provider ghidra` and `rea decompile rea_riscv_entry`, committing `RISCV:LE:32:default` and `RISCV:LE:64:default` with `language_selection: provider-pinned-v1`; the soft-float RV64 shape is refused with the named float-ABI reason, and the full refusal matrix including the RV32 and width-mismatch shapes is covered by unit tests. No `verify:ghidra:riscv` lane script is included in this change; the runs above were performed manually against the installed engine. Fork pull requests receive no CI check runs, so the real-provider scope above is the evidence available for review. * fix(ghidra): bound the RISC-V lane by declared flags and document it Address review feedback on #2151. `e_flags` bits outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI` are now refused with the offending value named, so a reserved or newer producer declaration cannot enter the verified generic lane by falling through the RVE, TSO and float-ABI checks. Admission reads `e_type` only, so the refusal text no longer calls `ET_EXEC` static: a fixed-address declaration does not establish static linkage, and `PT_INTERP` and dynamic dependencies are not inspected. The session records that boundary as a limitation rather than inferring a load model. Add `docs/ghidra-riscv.md` in the shape of the MIPS lane guide (admission boundary, refusal table, why the language is pinned per class, Evidence fields, and what the fixtures do not establish), and correct `docs/firmware-analysis.md`, which still told users that MIPS and RISC-V decompilation had not been added. Verification after the change: `npm run check` and `npm run docs:check` pass; the RISC-V module tests pass (17 lane cases plus 9 parser cases); on Ghidra 12.1.4 with JDK 21.0.12.1 the RV32 and RV64 double-float fixtures still analyze and decompile with `RISCV:LE:32:default` / `RISCV:LE:64:default` and `language_selection: provider-pinned-v1`, the soft-float fixture is still refused by name, and the new linkage limitation appears in the emitted Evidence. * fix(ghidra): align RISC-V pinning with admission Reuse the complete lane admission boundary when selecting a language, retain unsigned unsupported flag bits in diagnostics, and derive float ABI metadata from the source declaration. * test(ghidra): verify RISC-V CLI and MCP workflows Build source-owned RV32/RV64 fixtures with Clang and LLD, establish independent ELF and symbol facts with LLVM readelf, and verify real provider evidence, instruction bytes, calls, decompilation, schema projections, snapshot replay, refusal diagnostics, source immutability and cleanup. Verified on macOS ARM64 with Ghidra 12.1.4, JDK 21 and LLVM/LLD 23.1.2. Dynamic linkage, big-endian, Andes and RVE/TSO workflows remain unverified; ISA attributes are not inspected. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/firmware-analysis.md | 6 +- docs/ghidra-riscv.md | 105 ++++++ docs/testing.md | 1 + package.json | 1 + scripts/verify-real-ghidra-riscv.mjs | 391 +++++++++++++++++++++ src/domain/binaryTarget.riscv.test.ts | 98 ++++++ src/domain/binaryTarget.ts | 79 +++-- src/domain/binaryTargetTypes.ts | 55 ++- src/domain/peMachine.ts | 4 +- src/domain/rawBinary.ts | 7 +- src/ghidra/GhidraAnalysisProfile.ts | 34 +- src/ghidra/GhidraProvider.ts | 1 + src/ghidra/GhidraProviderClient.ts | 6 +- src/ghidra/GhidraPspProfile.ts | 3 + src/ghidra/GhidraRawBinaryLanguage.ts | 4 +- src/ghidra/GhidraRiscvProfile.test.ts | 256 ++++++++++++++ src/ghidra/GhidraRiscvProfile.ts | 129 +++++++ src/hopper/HopperAnalysisProfile.ts | 21 +- src/hopper/HopperMachOImage.ts | 6 +- src/hopper/HopperProvider.ts | 11 +- src/ida/IdaProvider.ts | 15 +- tests/conformance/c/riscv.c | 18 + tests/conformance/ghidra/README.md | 8 + tests/conformance/ghidra/riscv-fixture.mjs | 97 +++++ tests/conformance/ghidra/riscv-probe.S | 9 + 25 files changed, 1293 insertions(+), 72 deletions(-) create mode 100644 docs/ghidra-riscv.md create mode 100644 scripts/verify-real-ghidra-riscv.mjs create mode 100644 src/domain/binaryTarget.riscv.test.ts create mode 100644 src/ghidra/GhidraRiscvProfile.test.ts create mode 100644 src/ghidra/GhidraRiscvProfile.ts create mode 100644 tests/conformance/c/riscv.c create mode 100644 tests/conformance/ghidra/riscv-fixture.mjs create mode 100644 tests/conformance/ghidra/riscv-probe.S diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 1674d8fd11..1f4ff14d60 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -123,8 +123,10 @@ Select a verified native child for the existing tools: ``` Then use `analyze_function`, `inspect_native_api` or the other native primitives. -Native architecture and executable validation still apply. This milestone does -not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device +Native architecture and executable validation still apply. Generic +[MIPS ELF](ghidra-mips.md) and [RISC-V ELF](ghidra-riscv.md) lanes are separate +documents with their own admission boundaries. This milestone does +not add PPC decompilation, raw flash load profiles, bootloader/device tree semantics, vulnerability scanning, emulation or hardware runtime capture. ## Resource and lifecycle policy diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md new file mode 100644 index 0000000000..e180122d71 --- /dev/null +++ b/docs/ghidra-riscv.md @@ -0,0 +1,105 @@ +# RISC-V ELF analysis through Ghidra + +This lane extends native ELF analysis for `EM_RISCV` targets. It does not +execute RISC-V code, emulate a boot sequence, or rehost firmware. It addresses +[#2063](https://github.com/morluto/rea/issues/2063) and follows the same +lane shape as [generic MIPS support](ghidra-mips.md). + +## Admission boundary + +ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The +target also retains the raw ELF declarations that decide a lane: class, byte +order, `e_type`, and `e_flags`. Recognition does not mean every provider or +RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets +are unchanged, and caller-supplied headerless raw-binary entry still accepts only +its existing four families. + +The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that +declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, +`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`. These are +header declarations, not proof that every instruction conforms to that ISA. + +Provider selection returns the specific unsupported constraint rather than +guessing a compatible profile: + +| Declaration | Outcome | +| ----------------------------------------------- | --------------------------------------------------------------------------- | +| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` | +| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` | +| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language | +| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value | +| `EF_RISCV_RVE` | refused; the embedded 15-register ABI needs its own processor specification | +| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here | +| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float | +| Reserved or newer `e_flags` bits | refused with the offending bits named | + +`e_type` is the only load-shape declaration this lane reads. A fixed-address +`ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and +dynamic dependencies are not inspected, and the session records that boundary as +a limitation rather than inferring a load model. + +## Why the language is pinned + +Ghidra's header-based selection is not reliable for RV32. The 12.1.4 +`riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the +32-bit constraint unpinned with the comment that the loader "must choose between +'default' and 'AndeStar' variants". Measured against the installed release tree: + +| Fixture | Ghidra auto-selection | +| ----------------------------------------------- | ----------------------------- | +| RV64 ELF, `e_flags 0x5` (RVC, double-float ABI) | `RISCV:LE:64:default:gcc` | +| RV32 ELF, `e_flags 0x5`, no Andes declaration | `RISCV:LE:32:AndeStar_v5:gcc` | + +So an ordinary RV32 image lands on an Andes processor model unless the language +is stated. REA therefore pins `RISCV:LE:32:default` or `RISCV:LE:64:default` +with the `gcc` compiler specification per class, records +`language_selection: provider-pinned-v1`, and refuses a caller-selected +`REA_GHIDRA_LANGUAGE_ID` or `REA_GHIDRA_COMPILER_SPEC_ID` that disagrees with the +pin without changing the requested override. An override that matches the pin is +accepted and recorded as `configured-v1`. + +## Evidence + +The lane identity is `elf32-exec-le-double-float-generic-v1` or +`elf64-exec-le-double-float-generic-v1`, and `riscv_elf` retains `elf_class`, +`byte_order`, `type`, and the raw `flags` alongside the read-only booleans +`float_abi_double`, `rvc_declared`, `rve_declared`, and `tso_declared`. Those +fields report declarations the header makes; none of them is evidence of the +original compiler, of instruction conformance, or of a floating-point calling +convention beyond the ABI the pinned language models. + +## Verification and limits + +Ghidra 12.1.4 PUBLIC with a 64-bit JDK 21+ is required, and on macOS the release +tree ships no host decompiler binary, so `Decompiler/os//decompile` and +`sleigh` must come from your own build; see +[installation](installation.md#ghidra). + +Run `npm run verify:ghidra:riscv` with an existing Ghidra 12.1.4 installation, +JDK 21+, and a RISC-V-capable Clang, LLD and LLVM readelf. The verifier acquires +no tools and never executes target code. Select installed tools explicitly when +they are not on `PATH`: + +```sh +GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \ +JAVA_HOME=/path/to/jdk-21 \ +REA_RISCV_CLANG=/path/to/llvm/bin/clang \ +REA_RISCV_LLD=/path/to/lld/bin/ld.lld \ +REA_RISCV_READELF=/path/to/llvm/bin/llvm-readelf \ +npm run verify:ghidra:riscv +``` + +The lane builds the committed freestanding C and assembly sources into RV32 and +RV64 `ET_EXEC` fixtures. Independent LLVM readelf observations establish ELF +declarations and symbol addresses. Public CLI and stdio MCP workflows verify +function enumeration, decompilation, pinned language/compiler identity, decoded +probe bytes, global data, and direct calls. It validates advertised MCP output +schemas and both JSON representations, saves and reopens snapshots, rejects +header-mutated soft-float and unsupported high-bit declarations, and checks +unchanged source bytes and owned cleanup. Failures retain the diagnostic +workspace; successful runs remove it and report the observed process lineage. + +Module tests cover admission and malformed headers. Call-graph completeness, +string inventories, dynamic linkage, Andes or RVE/TSO images, and big-endian +targets are **not** established by this lane. `.riscv.attributes` ISA extensions +are not inspected; the generic language pin remains an analysis convention. diff --git a/docs/testing.md b/docs/testing.md index b80ff8f787..7bb14effeb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -308,6 +308,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | diff --git a/package.json b/package.json index e5de6a3dbe..dd0ef57d80 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", + "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", "verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs new file mode 100644 index 0000000000..a87adeeb5b --- /dev/null +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import Ajv from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + mcpTextValue, + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-riscv.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error("This optional lane requires a Linux/macOS Ghidra host."); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +for (const key of ["REA_GHIDRA_LANGUAGE_ID", "REA_GHIDRA_COMPILER_SPEC_ID"]) + if (process.env[key] !== undefined) + throw new Error( + `Unset ${key}: this lane verifies provider-pinned selection.`, + ); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); +assert.equal(installation.providerVersion, "12.1.4"); + +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-riscv-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: installation.installDir, + JAVA_HOME: installation.javaHome, +}; +for (const key of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) + if (process.env[key] !== undefined) env[key] = process.env[key]; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-riscv-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); + +const reports = []; +let failure; +try { + await client.connect(transport); + const catalog = await client.listTools(); + const ajv = new Ajv({ strict: false }); + addFormats(ajv); + const validators = new Map(); + for (const tool of catalog.tools) { + assert.ok( + ajv.validateSchema(tool.outputSchema), + `${tool.name}: invalid output schema`, + ); + const existing = + typeof tool.outputSchema.$id === "string" + ? ajv.getSchema(tool.outputSchema.$id) + : undefined; + validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema)); + } + for (const bits of [32, 64]) { + const fixture = await buildRiscvFixture(workspace, bits); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.architecture, "riscv"); + assert.equal(resolved.value.sha256, fixture.sha256); + assert.deepEqual(resolved.value.riscv, fixture.riscv); + const expectedLanguage = `RISCV:LE:${bits}:default`; + const assertProfile = (profile) => { + assert.equal(profile.provider.id, "ghidra"); + assert.equal(profile.provider.version, installation.providerVersion); + const parameters = profile.parameters; + assert.equal(parameters.language_id, expectedLanguage); + assert.equal(parameters.compiler_spec_id, "gcc"); + assert.equal(parameters.language_selection, "provider-pinned-v1"); + assert.equal( + parameters.riscv_support_lane, + `elf${bits}-exec-le-double-float-generic-v1`, + ); + assert.deepEqual(parameters.riscv_elf, { + elf_class: bits, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + }; + const assertEvidence = (evidence) => { + assert.equal(evidence.provider.id, "ghidra"); + assert.equal(evidence.subject.architecture, "riscv"); + assert.equal(evidence.subject.digest.sha256, fixture.sha256); + assertProfile(evidence.analysis_profile); + }; + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + const projected = requireMcpOperationResult(reply, name); + const textual = JSON.parse(mcpTextValue(reply)); + assert.deepEqual(reply.structuredContent, textual); + const validate = validators.get(name); + assert.ok(validate, `${name} missing from advertised catalog`); + assert.ok( + validate(textual), + `${name}: ${JSON.stringify(validate.errors)}`, + ); + if (!["open_binary", "close_binary", "binary_session"].includes(name)) + assertEvidence(parseEvidence(textual)); + return projected; + }; + const opened = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "riscv"); + assert.equal(opened.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assert.equal(image.observations.language_id, expectedLanguage); + assert.equal(image.observations.compiler_spec_id, "gcc"); + assert.ok(image.observations.source_files.length > 0); + for (const source of image.observations.source_files) { + assert.equal(source.original_sha256, fixture.sha256); + assert.equal(source.modified_sha256, fixture.sha256); + } + const procedures = await call("list_procedures"); + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) { + const procedure = procedures.find((value) => value.value === name); + assert.ok(procedure, `${name} missing from Ghidra inventory`); + assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name])); + } + const probe = await call("inspect_native_instruction", { + address: fixture.symbols.rea_riscv_probe, + }); + assert.equal(probe.status, "decoded"); + assert.equal(probe.architecture, expectedLanguage); + assert.equal(probe.length, 4); + assert.equal(probe.bytes, "13051501"); // addi a0, a0, 17 + const mapping = await call("address_to_file_offset", { + address: fixture.symbols.rea_riscv_probe, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal( + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 4) + .toString("hex"), + probe.bytes, + ); + const global = await call("read_bytes", { + address: fixture.symbols.rea_riscv_global, + length: 4, + }); + assert.equal(global.bytes_hex, "07000000"); + const callees = await call("procedure_callees", { + procedure: fixture.symbols.rea_riscv_entry, + }); + for (const name of ["rea_riscv_leaf", "rea_riscv_probe"]) + assert.ok( + callees.some( + (address) => BigInt(address) === BigInt(fixture.symbols[name]), + ), + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: fixture.symbols.rea_riscv_entry, + }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const floatCode = await call("procedure_pseudo_code", { + procedure: fixture.symbols.rea_riscv_float, + }); + assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0); + assert.match(floatCode, /\bdouble\b/u); + + const refusals = []; + for (const [flags, reason] of [ + [1, "EF_RISCV_FLOAT_ABI_DOUBLE"], + [0x80000005, "(0x80000000)"], + ]) { + // Header-mutated copies test admission; they are not new compiler claims. + const rejectedPath = join( + workspace, + `rv${bits}-flags-${flags.toString(16)}.elf`, + ); + const rejectedBytes = Buffer.from(sourceBytes); + rejectedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + await writeFile(rejectedPath, rejectedBytes); + const reply = await client.callTool( + { + name: "open_binary", + arguments: { path: rejectedPath, provider_id: "ghidra" }, + }, + { timeout: 60000 }, + ); + const error = requireMcpToolError(reply); + assert.ok(JSON.stringify(error).includes(reason), JSON.stringify(error)); + await assert.rejects( + execute( + process.execPath, + [ + entrypoint, + "function", + rejectedPath, + "rea_riscv_entry", + "--provider", + "ghidra", + "--json", + ], + { + env, + timeout: 60000, + maxBuffer: 1024 * 1024, + }, + ), + (cause) => + Number.isInteger(cause.code) && + cause.code !== 0 && + `${cause.stdout}\n${cause.stderr}`.includes(reason), + ); + refusals.push({ flags, reason }); + } + const snapshotPath = join(workspace, `rv${bits}.snapshot.json`); + const saved = await call("close_binary", { snapshot_path: snapshotPath }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "riscv"); + assert.equal(snapshot.target.sha256, fixture.sha256); + assertProfile(snapshot.binding.analysis_profile); + for (const record of snapshot.evidence_bundle.records) + assertEvidence(parseEvidence(record)); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + for (const [command, arguments_] of [ + ["search", ["rea_riscv_", "--kind", "procedures"]], + ["function", ["rea_riscv_entry"]], + ]) { + const cli = await execute( + process.execPath, + [ + entrypoint, + command, + fixture.path, + ...arguments_, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(cli.stdout)); + assertEvidence(evidence); + if (command === "search") + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]) + assert.ok( + evidence.normalized_result.some((value) => value.value === name), + ); + else { + const cliDossier = functionDossierSchema.parse( + evidence.normalized_result, + ); + assert.equal(cliDossier.procedure.address, dossier.procedure.address); + assert.ok(cliDossier.pseudocode.trim().length > 0); + } + } + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + reports.push({ + ...fixture, + language: expectedLanguage, + procedures: procedures.length, + refusals, + cli: true, + stdio_mcp: true, + snapshot_replay: true, + original_unchanged: true, + }); + } +} catch (cause) { + failure = cause; +} +for (const close of [() => client.close(), () => transport.close()]) { + try { + await close(); + } catch (cause) { + failure = new AggregateError( + failure === undefined ? [cause] : [failure, cause], + "Verification or cleanup failed", + ); + } +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, { + cause: failure, + }); +if (failure !== undefined) + throw new Error( + `RISC-V verification failed; retained workspace: ${workspace}; MCP stderr: ${stderr}`, + { cause: failure }, + ); +assert.deepEqual( + await readdir(runtime), + [], + `Runtime resources survived server shutdown; retained workspace: ${workspace}`, +); +await rm(workspace, { recursive: true, force: true }); +console.log( + JSON.stringify( + { + verifier_run: completed, + lane: "ghidra-riscv", + mocked: false, + host: { platform: process.platform, architecture: process.arch }, + provider: { id: "ghidra", version: installation.providerVersion }, + fixtures: reports, + cleanup: "complete", + }, + null, + 2, + ), +); diff --git a/src/domain/binaryTarget.riscv.test.ts b/src/domain/binaryTarget.riscv.test.ts new file mode 100644 index 0000000000..fcc77cf891 --- /dev/null +++ b/src/domain/binaryTarget.riscv.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = ( + little: boolean, + bits: 32 | 64 = 32, + flags = 0x5, + type = 2, + machine = 243, +): Buffer => { + const bytes = Buffer.alloc(bits === 32 ? 52 : 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]); + if (little) { + bytes.writeUInt16LE(type, 16); + bytes.writeUInt16LE(machine, 18); + bytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + } else { + bytes.writeUInt16BE(type, 16); + bytes.writeUInt16BE(machine, 18); + bytes.writeUInt32BE(flags, bits === 32 ? 36 : 48); + } + return bytes; +}; + +describe("RISC-V ELF header boundaries", () => { + it.each([ + [true, 32], + [false, 32], + [true, 64], + [false, 64], + ] as const)( + "retains class, endian, e_type and e_flags (little=%s, class=%s)", + (little, bits) => { + const parsed = parseExecutableHeader(header(little, bits), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: bits, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x5, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset", () => { + const bytes = header(true, 64); + bytes.writeUInt32LE(0xdeadbeef, 36); + bytes.writeUInt32LE(0x00000014, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "riscv", + riscv: { elfClass: 64, flags: 0x14 }, + }); + }); + + it("does not attach ELF metadata to a family that declares none", () => { + const parsed = parseExecutableHeader(header(true, 64, 0x5, 2, 62), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }); + }); + + it("rejects a truncated header before reading flags", () => { + const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64"); + expect(parsed).toEqual({ + ok: false, + error: + "truncated ELF header: an ELF32 header needs 52 bytes; the file has 39", + }); + }); + + it("does not turn invalid byte order into little-endian RISC-V", () => { + const bytes = header(true); + bytes[5] = 0; + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "unsupported ELF endianness", + }); + }); + + it("keeps an unknown e_machine outside the recognized family set", () => { + expect(parseExecutableHeader(header(true, 32, 0x5, 2, 244), "x64")).toEqual( + { + ok: false, + error: "unsupported ELF architecture", + }, + ); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 79a9daefab..29818b674e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,6 +1,9 @@ import type { BinaryArchitecture, + ElfFamilyMetadata, + HeaderArchitecture, MipsElfMetadata, + RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -15,31 +18,28 @@ export type ExecutableMetadata = readonly availableArchitectures: readonly "mips"[]; readonly mips: MipsElfMetadata; } + | { + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice?: never; } | { readonly format: "mach-o-universal"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice: UniversalMachOSlice; } | { readonly format: "pe"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly executableRole: | "application" | "shared-library" @@ -115,9 +115,9 @@ const parseFatMachO = ( const entrySize = is64 ? 32 : 20; if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); - const architectures: Exclude[] = []; + const architectures: HeaderArchitecture[] = []; const slices = new Map< - Exclude, + HeaderArchitecture, { readonly offset: bigint; readonly size: bigint } >(); for (let index = 0; index < count; index += 1) { @@ -191,20 +191,31 @@ const parseElf = ( const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); - if (architecture === "mips") - return ok({ - format: "elf", - architecture, - availableArchitectures: [architecture], - mips: { - elfClass: bytes[4] === 1 ? 32 : 64, - byteOrder: little ? "little" : "big", - type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), - flags: little - ? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48) - : bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48), - }, - }); + if (architecture === "mips" || architecture === "riscv") { + const elfClass = bytes[4] === 1 ? 32 : 64; + const flagsOffset = elfClass === 32 ? 36 : 48; + const metadata: ElfFamilyMetadata = { + elfClass, + byteOrder: little ? "little" : "big", + type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), + flags: little + ? bytes.readUInt32LE(flagsOffset) + : bytes.readUInt32BE(flagsOffset), + }; + return architecture === "mips" + ? ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + mips: metadata, + }) + : ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + riscv: metadata, + }); + } return ok({ format: "elf", architecture, @@ -299,9 +310,7 @@ const peManagedStatus = ( ); }; -const machArchitecture = ( - cpu: number, -): Exclude | undefined => { +const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { switch (cpu) { case 7: return "x86"; @@ -327,6 +336,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return "arm"; case 183: return "arm64"; + case 243: // EM_RISCV: XLEN, byte order and e_flags remain separate ELF facts. + return "riscv"; } return undefined; }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e73d86cec6..b4d44c1101 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -5,15 +5,44 @@ export const BINARY_ARCHITECTURES = [ "arm", "arm64", "mips", + "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; -/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ -export interface MipsElfMetadata { +/** + * ELF header facts for a family whose ISA and ABI are not established by the + * family name alone. Consumers admit a lane from these declarations rather than + * re-reading the header. + */ +export interface ElfFamilyMetadata { readonly elfClass: 32 | 64; readonly byteOrder: "little" | "big"; readonly type: number; readonly flags: number; +} + +/** Families whose parsed target variant carries `ElfFamilyMetadata`. */ +export const ELF_METADATA_ARCHITECTURES = [ + "mips", + "riscv", +] as const satisfies readonly BinaryArchitecture[]; +export type ElfMetadataArchitecture = + (typeof ELF_METADATA_ARCHITECTURES)[number]; + +/** Families whose ISA and ABI need a separate ELF declaration to be admitted. */ +export const isElfMetadataArchitecture = ( + architecture: BinaryArchitecture, +): architecture is ElfMetadataArchitecture => + (ELF_METADATA_ARCHITECTURES as readonly string[]).includes(architecture); + +/** Recognized family with no ELF metadata member; the header alone is the fact. */ +export type HeaderArchitecture = Exclude< + BinaryArchitecture, + ElfMetadataArchitecture +>; + +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface MipsElfMetadata extends ElfFamilyMetadata { /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; /** Unavailable declaration sources; missing records there remain unknown. */ @@ -44,6 +73,12 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** + * ELF class and flags retain the width and ABI declarations used by this lane. + * ISA extensions in `.riscv.attributes` are not inspected by this header parser. + */ +export type RiscvElfMetadata = ElfFamilyMetadata; + /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -67,11 +102,8 @@ type NonExecutableMetadata = { type ExecutableTarget = BinaryTargetIdentity & { readonly kind: "executable"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; }; /** @@ -88,6 +120,15 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly universalSlice?: never; diff --git a/src/domain/peMachine.ts b/src/domain/peMachine.ts index 16a5ace371..18981c88c6 100644 --- a/src/domain/peMachine.ts +++ b/src/domain/peMachine.ts @@ -1,6 +1,6 @@ -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { HeaderArchitecture } from "./binaryTargetTypes.js"; -export type PeArchitecture = Exclude; +export type PeArchitecture = HeaderArchitecture; /** A decoded COFF machine and whether it carried a ReadyToRun OS override. */ export interface PeMachine { diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index d00af8d498..94fdf6ce71 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { + BinaryArchitecture, + HeaderArchitecture, +} from "./binaryTargetTypes.js"; import type { ExecutableFormatHint } from "./dosCom.js"; import { err, ok, type Result } from "./result.js"; @@ -55,7 +58,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: Exclude; + readonly architecture: HeaderArchitecture; readonly entryPoint: string; readonly entryPointSource: string; } diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a736cd0bf4..60edb0971b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -17,6 +17,10 @@ import { ghidraMipsProfileParameters, ghidraMipsSharedLanguageOverride, } from "./GhidraMipsProfile.js"; +import { + ghidraRiscvLanguageOverride, + ghidraRiscvProfileParameters, +} from "./GhidraRiscvProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -32,6 +36,17 @@ import { NATIVE_AOT_PROFILE_CONTRACT_REVISION, } from "../domain/native/nativeAotPe.js"; +/** + * The processor language REA pins for this target, or undefined when Ghidra may + * select from the header. The committed profile and the session readback both + * compare against this single owner rather than re-deriving a lane. + */ +export const ghidraPinnedLanguage = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + ghidraMipsSharedLanguageOverride(target) ?? + ghidraRiscvLanguageOverride(target); + /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( target: BinaryTarget, @@ -136,22 +151,26 @@ export const resolveGhidraAnalysisProfile = async ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; - const mipsLanguage = ghidraMipsSharedLanguageOverride(target); + const pinnedLanguage = ghidraPinnedLanguage(target); if ( - mipsLanguage !== undefined && + pinnedLanguage !== undefined && languageOverride !== undefined && - (languageOverride.languageId !== mipsLanguage.languageId || + (languageOverride.languageId !== pinnedLanguage.languageId || (languageOverride.compilerSpecId !== undefined && - languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId)) + languageOverride.compilerSpecId !== pinnedLanguage.compilerSpecId)) ) return err( new AnalysisUnsupportedTargetError( "resolve_analysis_profile", target.path, - "The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.", + `The caller-selected processor or compiler specification is outside the verified ${ + target.architecture === "riscv" + ? "generic little-endian RISC-V" + : "endian-matched MIPS32 shared-image" + } profile. The requested override has not been changed.`, ), ); - const selectedLanguage = languageOverride ?? mipsLanguage; + const selectedLanguage = languageOverride ?? pinnedLanguage; // DOS imports already force an admitted loader, language, and context. if (dos && languageOverride !== undefined) return Promise.resolve( @@ -227,7 +246,7 @@ export const resolveGhidraAnalysisProfile = async ( ? "auto-default" : (selectedLanguage.compilerSpecId ?? "language-default"), ...(languageOverride === undefined - ? mipsLanguage === undefined + ? pinnedLanguage === undefined ? {} : { language_selection: "provider-pinned-v1" } : { language_selection: "configured-v1" }), @@ -251,6 +270,7 @@ export const resolveGhidraAnalysisProfile = async ( : {}), ...analyzerParameters, ...ghidraMipsProfileParameters(target), + ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2", diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index aa50440797..c48f589576 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "riscv", ]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index fc0297789b..39c89c46ae 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -14,6 +14,7 @@ import { ghidraSeedLimitations, } from "./GhidraAnalysisSeeds.js"; import { + ghidraPinnedLanguage, ghidraProfileDisabledAnalyzers, ghidraProfileLanguageOverride, sameGhidraDisabledAnalyzers, @@ -61,10 +62,10 @@ import { ghidraMipsSoftFloatFailure, ghidraMipsRelocatableLimitations, ghidraMipsSharedLimitations, - ghidraMipsSharedLanguageOverride, ghidraMipsSoftFloatLimitations, isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -453,7 +454,7 @@ export const createGhidraProviderClient = (input: { if ( !sameGhidraLanguageOverride( languageOverride, - config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target), + config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), ) ) return unavailableClient( @@ -523,6 +524,7 @@ export const createGhidraProviderClient = (input: { : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), + ...ghidraRiscvLimitations(target), ]; const providerLimitations = installation.platform === "win32" diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index b1ee12fe55..17260d038e 100644 --- a/src/ghidra/GhidraPspProfile.ts +++ b/src/ghidra/GhidraPspProfile.ts @@ -1,5 +1,6 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; +import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; /** Allegrex's ELF machine declaration, not a filename or inferred instruction. */ export const isGhidraPspTarget = (target: BinaryTarget): boolean => @@ -13,6 +14,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean => export const ghidraProcessorUnsupportedReason = ( target: BinaryTarget, ): string | null => { + if (target.kind === "executable" && target.architecture === "riscv") + return ghidraRiscvUnsupportedReason(target); if ( target.kind !== "executable" || target.architecture !== "mips" || diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index 9a4f254b28..ea024bdbaa 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,4 +1,4 @@ -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { @@ -13,7 +13,7 @@ export interface GhidraRawBinaryLanguage { * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: Exclude, + architecture: HeaderArchitecture, ): GhidraRawBinaryLanguage => { switch (architecture) { case "x86": diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts new file mode 100644 index 0000000000..9bbb65cf32 --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + RiscvElfMetadata, +} from "../domain/binaryTargetTypes.js"; +import { silentLogger } from "../logger.js"; +import { HopperProvider } from "../hopper/HopperProvider.js"; +import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; +import { IdaProvider } from "../ida/IdaProvider.js"; +import { GhidraProvider } from "./GhidraProvider.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/RISCV/data/languages/riscv.ldefs. +// The release tree ships no big-endian or ELF-class-mismatched RISC-V language. +const RISCV_LDEFS = ` + + + RISC-V 64 little default + + + + RISC-V 32 little default + + + +`; + +// Fake installation inspection tests admission/profile contracts, not Ghidra. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; +const configOf = ( + installDir: string, + environment: Record = {}, +) => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: installDir, + ...environment, + }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: 64, + byteOrder: "little", + type: 2, + flags: 0x5, + ...change, + }, +}); + +let installDir = ""; +beforeAll(async () => { + installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); +}); +afterAll(async () => { + await rm(installDir, { recursive: true, force: true }); +}); + +const ghidra = (environment: Record = {}) => + new GhidraProvider(configOf(installDir, environment), silentLogger, {}, host); + +describe("bounded Ghidra RISC-V admission", () => { + it("pins the generic language per width instead of accepting the header guess", async () => { + const provider = ghidra(); + const rv64 = target(); + const rv32 = target({ elfClass: 32, flags: 0x4 }); + expect(provider.inspectTargetSupport(rv64).status).toBe("supported"); + expect(provider.inspectTargetSupport(rv32).status).toBe("supported"); + expect(ghidraPinnedLanguage(rv64)).toEqual({ + languageId: "RISCV:LE:64:default", + compilerSpecId: "gcc", + }); + expect(ghidraPinnedLanguage(rv32)).toEqual({ + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }); + const wide = await provider.resolveAnalysisProfile(rv64); + const narrow = await provider.resolveAnalysisProfile(rv32); + if (!wide.ok || !narrow.ok) + throw new Error("expected admitted RISC-V profiles"); + expect(wide.value.profile?.parameters.riscv_elf).toEqual({ + elf_class: 64, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(wide.value.profile?.parameters.riscv_support_lane).toBe( + "elf64-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_support_lane).toBe( + "elf32-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_elf).toMatchObject({ + elf_class: 32, + rvc_declared: false, + }); + expect(wide.value.profile?.digest).not.toBe(narrow.value.profile?.digest); + }); + + it("commits the pinned language the session readback expects", async () => { + // A pinned lane has one owner: the committed profile and the open_binary + // readback must agree without either re-deriving a different family's pin. + const provider = ghidra(); + for (const value of [target(), target({ elfClass: 32 })]) { + const resolved = await provider.resolveAnalysisProfile(value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a RISC-V profile"); + expect( + sameGhidraLanguageOverride( + ghidraProfileLanguageOverride(resolved.value.profile.parameters), + ghidraPinnedLanguage(value), + ), + ).toBe(true); + } + }); + + it.each([ + [{ byteOrder: "big" }, "little-endian"], + [{ type: 3 }, "fixed-address ET_EXEC only"], + [{ type: 1 }, "fixed-address ET_EXEC only"], + [{ type: 0xffa0 }, "e_type 65440"], + [{ flags: 0xc }, "EF_RISCV_RVE"], + [{ flags: 0x14 }, "EF_RISCV_TSO"], + [{ flags: 0x1 }, "soft-float convention"], + [{ flags: 0x3 }, "single-float"], + [{ flags: 0x7 }, "quad-float"], + [{ flags: 0x8000_0005 }, "(0x80000000)"], + [{ flags: 0x24 }, "bits outside the verified lane"], + ] satisfies [Partial, string][])( + "refuses an unverified RISC-V interpretation %j", + async (change, reason) => { + const provider = ghidra(); + const value = target(change); + expect(provider.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + reason: expect.stringContaining(reason), + }); + expect(await provider.resolveAnalysisProfile(value)).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + }, + ); + + it("refuses a caller-selected processor outside the verified generic lane", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:32:AndeStar_v5", + }).resolveAnalysisProfile(target({ elfClass: 32 })); + expect(resolved.ok).toBe(false); + if (resolved.ok) throw new Error("expected a refusal"); + expect(resolved.error.message).toContain("generic little-endian RISC-V"); + }); + + it("keeps a caller-selected language that matches the pin as configured", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:64:default", + REA_GHIDRA_COMPILER_SPEC_ID: "gcc", + }).resolveAnalysisProfile(target()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a configured profile"); + expect(resolved.value.profile.parameters.language_selection).toBe( + "configured-v1", + ); + expect(resolved.value.profile.parameters.language_id).toBe( + "RISCV:LE:64:default", + ); + }); + + it("states the linkage claim boundary in the lane limitations", () => { + const notes = ghidraRiscvLimitations(target()); + expect(notes).toHaveLength(3); + expect(notes.join(" ")).toContain("does not establish static linkage"); + expect(notes[0]).toContain("RISCV:LE:64:default"); + expect( + ghidraRiscvLimitations({ + path: "/other.elf", + sha256: "b".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }), + ).toEqual([]); + }); + + it("keeps RISC-V out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider( + configOf("/hopper"), + silentLogger, + {}, + "linux", + ); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + expect(hopperLoaderArgsForTarget(target())).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("RISC-V"), + }), + }), + ); + const ida = new IdaProvider(configOf("/ida"), {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraRiscvProfile.ts b/src/ghidra/GhidraRiscvProfile.ts new file mode 100644 index 0000000000..3ccd5fc4cb --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.ts @@ -0,0 +1,129 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; + +/** + * Ghidra's generic RISC-V languages model the double-float ABI: the shipped + * `riscv.ldefs` maps `RISCV:LE:{32,64}:default` to the `riscv{32,64}-fp.cspec` + * compiler specification. Soft, single and quad ABI declarations therefore need + * their own verification rather than inheriting this lane. + */ +const EF_RISCV_RVC = 0x0001; +const EF_RISCV_FLOAT_ABI = 0x0006; +const EF_RISCV_FLOAT_ABI_DOUBLE = 0x0004; +const EF_RISCV_RVE = 0x0008; +const EF_RISCV_TSO = 0x0010; + +const ET_REL = 1; +const ET_EXEC = 2; +const ET_DYN = 3; + +/** + * Bits this lane reads. Anything else in `e_flags` — reserved today or defined by + * a newer psABI — is refused rather than assumed compatible. + */ +const EF_RISCV_DECLARED_BITS = EF_RISCV_RVC | EF_RISCV_FLOAT_ABI; + +/** Header declarations outside the verified generic-language lane. */ +const riscvUnsupportedFloatAbi = (abi: number): string => + abi === 0 + ? "unspecified (soft-float convention)" + : abi === 0x0002 + ? "single-float" + : "quad-float"; + +/** + * Keep family recognition separate from the deliberately bounded Ghidra lane. + * + * Ghidra 12.1.4 selects `RISCV:LE:32:AndeStar_v5` for an ordinary RV32 ELF: its + * `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves + * the 32-bit one with the comment that the loader "must choose between 'default' + * and 'AndeStar' variants". The lane therefore pins the language instead of + * accepting the header-based guess. + */ +export const ghidraRiscvUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "riscv") + return null; + const metadata = target.riscv; + if (target.format !== "elf") + return "RISC-V analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + if (metadata.byteOrder !== "little") + return "This Ghidra RISC-V lane supports little-endian images only; Ghidra 12.1.4 ships no big-endian RISC-V language."; + if (metadata.elfClass !== 32 && metadata.elfClass !== 64) + return "This Ghidra RISC-V lane requires ELF32 or ELF64; the class decides which generic language is pinned."; + if ((metadata.flags & EF_RISCV_RVE) !== 0) + return "EF_RISCV_RVE declares the embedded 15-register ABI, which needs its own processor specification and compiler verification."; + if ((metadata.flags & EF_RISCV_TSO) !== 0) + return "EF_RISCV_TSO declares the total-store-order memory model; this lane verifies the baseline model only."; + if (metadata.type !== ET_EXEC) + return `This Ghidra RISC-V lane supports fixed-address ET_EXEC only; ${ + metadata.type === ET_REL + ? "relocatable" + : metadata.type === ET_DYN + ? "shared and position-independent" + : `e_type ${metadata.type}` + } images need separate load verification. ET_EXEC alone does not establish static linkage, which this lane does not inspect.`; + const abi = metadata.flags & EF_RISCV_FLOAT_ABI; + if (abi !== EF_RISCV_FLOAT_ABI_DOUBLE) + return `This Ghidra RISC-V lane requires the EF_RISCV_FLOAT_ABI_DOUBLE declaration (${riscvUnsupportedFloatAbi( + abi, + )} observed); the pinned generic language uses the double-float compiler specification.`; + const unsupportedFlags = (metadata.flags & ~EF_RISCV_DECLARED_BITS) >>> 0; + if (unsupportedFlags !== 0) + return `RISC-V e_flags declares bits outside the verified lane (0x${unsupportedFlags.toString(16)}); reserved or newer declarations are not treated as compatible with this profile.`; + return null; +}; + +/** Pin a language only for declarations admitted by the same lane boundary. */ +export const ghidraRiscvLanguageOverride = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + target.kind === "executable" && + target.architecture === "riscv" && + ghidraRiscvUnsupportedReason(target) === null + ? { + languageId: `RISCV:LE:${target.riscv.elfClass}:default`, + compilerSpecId: "gcc", + } + : undefined; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +export const ghidraRiscvProfileParameters = ( + target: BinaryTarget, +): Readonly> => + target.kind === "executable" && + target.architecture === "riscv" && + target.riscv !== undefined + ? { + riscv_elf: { + elf_class: target.riscv.elfClass, + byte_order: target.riscv.byteOrder, + type: target.riscv.type, + flags: target.riscv.flags, + float_abi_double: + (target.riscv.flags & EF_RISCV_FLOAT_ABI) === + EF_RISCV_FLOAT_ABI_DOUBLE, + rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0, + rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0, + tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0, + }, + riscv_support_lane: + target.riscv.elfClass === 32 + ? "elf32-exec-le-double-float-generic-v1" + : "elf64-exec-le-double-float-generic-v1", + } + : {}; + +/** The pinned language is an analysis convention, not proof of the producer. */ +export const ghidraRiscvLimitations = ( + target: BinaryTarget, +): readonly string[] => + target.kind === "executable" && target.architecture === "riscv" + ? [ + `RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`, + "The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics.", + "Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.", + ] + : []; diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 927f7289b2..299862a97e 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -8,8 +8,10 @@ import type { import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, - type BinaryArchitecture, + type ElfMetadataArchitecture, + type HeaderArchitecture, } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, @@ -101,6 +103,15 @@ export const resolveHopperAnalysisProfile = async ( }); }; +/** Ghidra owns these lanes; Hopper names the family it declines rather than guessing. */ +const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record< + ElfMetadataArchitecture, + string +> = { + mips: "MIPS", + riscv: "RISC-V", +}; + /** Derive Hopper's complete non-interactive CLI loader selection. */ export const hopperLoaderArgsForTarget = ( target: BinaryTarget, @@ -113,12 +124,12 @@ export const hopperLoaderArgsForTarget = ( if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; - if (architecture === "mips") + if (isElfMetadataArchitecture(architecture)) return err( new AnalysisCapabilityUnavailableError( "hopper", "resolve_analysis_profile", - "REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.", + `REA's Hopper adapter does not admit ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} targets. Select a verified Ghidra ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} profile instead.`, ), ); const flag = hopperArchitectureFlag(architecture); @@ -151,9 +162,7 @@ export const hopperLoaderArgsForTarget = ( } }; -const hopperArchitectureFlag = ( - architecture: Exclude, -): string => { +const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => { switch (architecture) { case "x86": return "--intel-32"; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 4995333161..20a53105c3 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; @@ -47,7 +48,10 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if (!isMachOFormat(target.format) || target.architecture === "mips") + if ( + !isMachOFormat(target.format) || + isElfMetadataArchitecture(target.architecture) + ) return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index c52eefe280..aec8c20829 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -16,7 +16,10 @@ import type { } from "../application/AnalysisProvider.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { AppConfig } from "../config/types.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; @@ -118,11 +121,13 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: null, diagnostics, }; - if (target.architecture === "mips") + if (isElfMetadataArchitecture(target.architecture)) return { status: "unsupported", code: "architecture_unsupported", - reason: "REA's Hopper adapter does not admit MIPS targets.", + reason: `REA's Hopper adapter does not admit ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } targets.`, diagnostics, }; if (target.format === "raw-binary") diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 7fefce825a..c86ef5eb44 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -1,7 +1,10 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { @@ -101,12 +104,16 @@ export class IdaProvider implements AnalysisProviderCandidate { target_kind: target.kind, target_format: target.format, }; - if (target.kind === "executable" && target.architecture === "mips") + if ( + target.kind === "executable" && + isElfMetadataArchitecture(target.architecture) + ) return { status: "unsupported", code: "architecture_unsupported", - reason: - "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", + reason: `REA's IDA adapter has no verified ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } lane. Select a supported Ghidra profile instead.`, diagnostics, }; if (target.kind !== "executable") diff --git a/tests/conformance/c/riscv.c b/tests/conformance/c/riscv.c new file mode 100644 index 0000000000..c100c4f186 --- /dev/null +++ b/tests/conformance/c/riscv.c @@ -0,0 +1,18 @@ +/* Source-owned freestanding analysis fixture. Never execute this target. */ +volatile unsigned int rea_riscv_global = 7; +extern unsigned int rea_riscv_probe(unsigned int value); + +__attribute__((noinline)) unsigned int rea_riscv_leaf(unsigned int value) +{ + return value * 3u + rea_riscv_global; +} + +__attribute__((noinline)) double rea_riscv_float(double left, double right) +{ + return left + right; +} + +__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector) +{ + return rea_riscv_probe(rea_riscv_leaf(selector + 1u)); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index ae9cc7b4c5..d2161e1ff3 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,14 @@ These source-owned fixtures are compiled at verification time; generated binaries and Ghidra projects are never committed. +- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images + with double-float ABI declarations and an independently known instruction. + `npm run verify:ghidra:riscv` exercises real CLI/MCP discovery, decompilation, + output schemas, profile and snapshot identity, refusal diagnostics, immutable + source bytes, and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and + RISC-V Clang/LLD/LLVM readelf; `REA_RISCV_CLANG`, `REA_RISCV_LLD` and + `REA_RISCV_READELF` select existing tool commands. + - `inventory.c` produces separate x86-64 debug and stripped ELF executables. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a diff --git a/tests/conformance/ghidra/riscv-fixture.mjs b/tests/conformance/ghidra/riscv-fixture.mjs new file mode 100644 index 0000000000..27b354094e --- /dev/null +++ b/tests/conformance/ghidra/riscv-fixture.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** Build and independently inspect a source-owned ELF; never execute it. */ +export async function buildRiscvFixture(directory, bits) { + assert.ok(bits === 32 || bits === 64); + const compiler = process.env.REA_RISCV_CLANG ?? "clang"; + const linker = process.env.REA_RISCV_LLD ?? "ld.lld"; + const reader = process.env.REA_RISCV_READELF ?? "llvm-readelf"; + const source = fileURLToPath(new URL("../c/riscv.c", import.meta.url)); + const probe = fileURLToPath(new URL("./riscv-probe.S", import.meta.url)); + const path = join(directory, `rv${bits}.elf`); + const args = [ + `--target=riscv${bits}-unknown-elf`, + `-march=rv${bits}imafdc`, + `-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`, + `--ld-path=${linker}`, + "-nostdlib", + "-static", + "-fno-pic", + "-O0", + "-g", + "-Wl,-e,rea_riscv_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const options = { timeout: 10000, maxBuffer: 1024 * 1024 }; + const compilerVersion = await execute(compiler, ["--version"], options); + const linkerVersion = await execute(linker, ["--version"], options); + const readerVersion = await execute(reader, ["--version"], options); + await execute(compiler, args, { ...options, timeout: 60000 }); + const { stdout, stderr } = await execute(reader, ["-h", "-s", "-W", path], { + ...options, + env: { ...process.env, LC_ALL: "C" }, + }); + assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics"); + const field = (expression, label) => { + const matches = [...stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), `ELF${bits}`); + assert.match(field(/^\s*Data:\s*(.+)$/gmu, "byte order"), /little endian/u); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^RISC-V\b/u); + const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags")); + assert.equal(flags, 5, "Compiler did not declare RVC and double-float ABI"); + const symbols = {}; + for (const name of [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + "rea_riscv_global", + ]) { + const value = field( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + name, + ); + symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; + } + return { + path, + sha256: createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + riscv: { elfClass: bits, byteOrder: "little", type: 2, flags }, + symbols, + toolchain: { + compiler: { command: compiler, version: compilerVersion.stdout.trim() }, + linker: { command: linker, version: linkerVersion.stdout.trim() }, + reader: { command: reader, version: readerVersion.stdout.trim() }, + arguments: args, + }, + sources: await Promise.all( + [source, probe].map(async (sourcePath) => ({ + path: sourcePath, + sha256: createHash("sha256") + .update(await readFile(sourcePath)) + .digest("hex"), + })), + ), + }; +} diff --git a/tests/conformance/ghidra/riscv-probe.S b/tests/conformance/ghidra/riscv-probe.S new file mode 100644 index 0000000000..701ca9582d --- /dev/null +++ b/tests/conformance/ghidra/riscv-probe.S @@ -0,0 +1,9 @@ +/* Fixed instruction oracle, independent of the compiler's C code generation. */ +.section .text +.option norvc +.global rea_riscv_probe +.type rea_riscv_probe, @function +rea_riscv_probe: + addi a0, a0, 17 + ret +.size rea_riscv_probe, .-rea_riscv_probe From 7724edbdd3fa0acd94c8210a30dceba501fcdbe8 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Mon, 12 Oct 2026 21:15:12 +0500 Subject: [PATCH 15/93] fix(process): report single empty settlement observation as unverifiable (#2232) observeSettlement classified a group as alive_at_deadline when the settlement budget expired after one empty observation, though no live member was ever seen. Track observed liveness and return unverifiable when the deadline ends observation with only empty rounds. The two-consecutive-empty rule for quiesced is unchanged, and the cleanup_outcome projection for non-quiesced settlements stays cleaned. Fixes #2170. --- .../capture/ProcessCaptureLifecycle.ts | 12 ++- .../process/processSettlementDeadline.test.ts | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/process/processSettlementDeadline.test.ts diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index ec31113ee8..2d2fcec00f 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -662,6 +662,7 @@ export const observeSettlement = async ( } const started = Date.now(); let consecutiveEmpty = 0; + let sawLive = false; let deadlineReached = false; while (!deadlineReached) { onLiveProgress?.(); @@ -685,13 +686,20 @@ export const observeSettlement = async ( recordEvent("lifecycle", 1); return { state: "quiesced", elapsed_ms: Date.now() - started }; } - } else consecutiveEmpty = 0; + } else { + consecutiveEmpty = 0; + sawLive = true; + } deadlineReached = Date.now() - started >= settleMs; if (deadlineReached) break; await delay(50, undefined, { signal }); } recordEvent("lifecycle", 1); - return { state: "alive_at_deadline", elapsed_ms: Date.now() - started }; + // A lone empty observation proves neither liveness nor quiescence; only an + // observed live member justifies alive_at_deadline. + return sawLive + ? { state: "alive_at_deadline", elapsed_ms: Date.now() - started } + : { state: "unverifiable", elapsed_ms: Date.now() - started }; }; /** Name why an attempt is unverified; a blank message never reaches the capture schema. */ diff --git a/tests/boundary/process/processSettlementDeadline.test.ts b/tests/boundary/process/processSettlementDeadline.test.ts new file mode 100644 index 0000000000..83c7483663 --- /dev/null +++ b/tests/boundary/process/processSettlementDeadline.test.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { observeSettlement } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; +import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js"; + +/** Process group id that cannot exist on any supported host. */ +const ABSENT_GROUP_ID = 4_294_967_295; + +const observeGroup = async (groupId: number) => + observeOwnedProcessGroups("settlement-deadline-probe", [groupId]); + +describe("settlement deadline classification", () => { + it("does not report a single empty observation as alive_at_deadline", async () => { + expect( + (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, + ).toBe("empty"); + + const settlement = await observeSettlement( + "settlement-single-empty", + [ABSENT_GROUP_ID], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("unverifiable"); + }); + + it("still reports observed liveness as alive_at_deadline", async () => { + const runId = "settlement-live-group"; + // A detached child leads its own process group and carries the run token, + // so the group observes as a genuinely owned live member. + const child = spawn("sleep", ["30"], { + detached: true, + stdio: "ignore", + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + }); + child.unref(); + try { + // Wait until the child is actually listed before asserting on it. + const started = Date.now(); + while ( + (await observeOwnedProcessGroups(runId, [child.pid!])).get(child.pid!) + ?.state !== "alive" + ) { + if (Date.now() - started > 5_000) + throw new Error("live fixture group never observed as alive"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const settlement = await observeSettlement( + runId, + [child.pid!], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("alive_at_deadline"); + } finally { + child.kill("SIGKILL"); + } + }); + + it("still reports two consecutive empty observations as quiesced", async () => { + const settlement = await observeSettlement( + "settlement-two-empty", + [ABSENT_GROUP_ID], + 1_000, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("quiesced"); + }); +}); From 85c5477930d504cfd8b72334b48255d8e746a85e Mon Sep 17 00:00:00 2001 From: Luis Araya <35828647+Totopo27@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:15:54 -0600 Subject: [PATCH 16/93] docs(readiness): align required stage identifier and checks with evaluator (#2200) --- docs/reconstruction-readiness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reconstruction-readiness.md b/docs/reconstruction-readiness.md index 1827311c49..cf4784aca8 100644 --- a/docs/reconstruction-readiness.md +++ b/docs/reconstruction-readiness.md @@ -36,7 +36,7 @@ check project-owned conformance fixtures and fail-closed report behavior. | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits | | `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery | -| `declare-request` | exact target, operation, and lifecycle are carried by the request before launch | +| `acquire-authority` | missing scope reported, narrow grant resumed, expansion reprompted, and denial launched nothing | | `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns | | `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence | | `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison | From d80b55e11bc47c6f999dac7dcb8382219d65739a Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:25:17 +0800 Subject: [PATCH 17/93] fix(adb): preserve existing bugreport archives (#2221) * fix(adb): preserve existing bugreport archives * fix(adb): retain bugreports after publication failure Record acquired archive facts before publishing, preserve the staging archive when the exclusive copy fails, and keep retained evidence out of automatic cleanup. Reuse the same observation through publication and digest failures. Add a real-filesystem ENOSPC regression covering error projection, existing evidence, subsequent collection and provider shutdown. Verified 96 focused tests, static checks and docs on macOS with simulated ADB; no real-device verification. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/adb-device-analysis.md | 5 + src/adb/AdbProvider.test.ts | 8 +- src/adb/AdbProvider.ts | 312 +++++++++++++----- src/domain/adb/adbDeviceAnalysis.ts | 22 +- .../adb/adbBugreportPublication.test.ts | 266 +++++++++++++++ 5 files changed, 511 insertions(+), 102 deletions(-) create mode 100644 tests/boundary/providers/adb/adbBugreportPublication.test.ts diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index fb2ce9aac7..e5a95c5365 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -148,6 +148,11 @@ existing analysis families directly, for example - Cancellation during a package pull preserves caller-selected output. The error includes partial observations identifying completed artifacts and the interrupted local path; remaining APKs were not attempted. +- Bugreports collect into isolated staging and publish with exclusive creation; + filename collisions select the next numeric suffix. If publication fails, the + completed archive remains in staging. The error's partial observation gives + its recoverable `local_path`, the original `adb_reported_path`, and an unknown + digest status. Later operations and shutdown preserve this retained evidence. - Failed package acquisition rollback retains its directory identity and retries cleanup before later work and during shutdown. Cleanup refuses a replacement directory. Terminal shutdown stops admission and drains active operations. diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 4d6c13001e..5f36917e4a 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1086,10 +1086,10 @@ it("rejects mistyped intent extras before touching the device", async () => { it("collects a device bugreport archive with its digest", async () => { const { binary, root } = await writeAdbStub( - (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then - printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}" - printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n' + printf 'bugreport-bytes' > "$4/bugreport test.zip" + printf 'Bug report copied to %s/bugreport test.zip\\n' "$4" exit 0 fi exit 1`, @@ -1103,7 +1103,7 @@ exit 1`, local_path: join(root, "bugreport test.zip"), bytes: 15, sha256: digest, - adb_reported_path: join(root, "bugreport test.zip"), + adb_reported_path: expect.stringContaining(".rea-bugreport-"), }); }); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 2a64930a56..bb2f49ce03 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2,14 +2,16 @@ import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { access, + copyFile, lstat, + mkdtemp, mkdir, rmdir, rm, stat, writeFile, } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, dirname, extname, join, resolve } from "node:path"; import type { ExecutionOptions, @@ -20,7 +22,10 @@ import { createAnalysisExecution, type AnalysisExecution, } from "../application/AnalysisProvider.js"; -import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; +import type { + AdbAcquiredFilePartialObservation, + AdbRequest, +} from "../domain/adb/adbDeviceAnalysis.js"; import { AnalysisArtifactChangedError, AnalysisCancelledError, @@ -305,30 +310,56 @@ const refuseExistingTarget = async ( return undefined; }; +/** Publish an ADB-produced archive without replacing an existing caller file. */ +const publishBugreport = async ( + source: string, + directory: string, +): Promise => { + const fileName = basename(source); + const extension = extname(fileName); + const stem = + extension === "" ? fileName : fileName.slice(0, -extension.length); + for (let suffix = 0; ; suffix += 1) { + const destination = join( + directory, + suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`, + ); + try { + await copyFile(source, destination, fsConstants.COPYFILE_EXCL); + return destination; + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "EEXIST") + continue; + throw cause; + } + } +}; + export interface AdbProviderOptions { readonly environment?: Readonly>; } -interface PendingPackageDirectoryCleanup { +interface PendingOutputDirectoryCleanup { + readonly operation: "pull_adb_package" | "collect_adb_bugreport"; readonly path: string; - readonly identity: PackageDirectoryIdentity | undefined; + readonly identity: OutputDirectoryIdentity | undefined; readonly primary: AnalysisError; readonly remove: () => Promise; } -interface PackageDirectoryIdentity { +interface OutputDirectoryIdentity { readonly dev: number; readonly ino: number; readonly mode: number; } -const removeOwnedPackageDirectory = async ( +const removeOwnedOutputDirectory = async ( path: string, - identity: PackageDirectoryIdentity | undefined, + identity: OutputDirectoryIdentity | undefined, ): Promise => { if (identity === undefined) throw new Error( - `Cannot verify the created package directory identity; refusing to remove ${path}`, + `Cannot verify the created output directory identity; refusing to remove ${path}`, ); let current: Awaited>; try { @@ -346,7 +377,7 @@ const removeOwnedPackageDirectory = async ( current.mode !== identity.mode ) throw new Error( - `The created package directory was replaced or changed; refusing to remove ${path}`, + `The created output directory was replaced or changed; refusing to remove ${path}`, ); await rm(path, { recursive: true, force: true }); }; @@ -363,7 +394,7 @@ const reservePackageDestinations = async ( ): Promise => { const reservations: { readonly path: string; - readonly identity: PackageDirectoryIdentity; + readonly identity: OutputDirectoryIdentity; }[] = []; for (const entry of entries) { const path = join(packageDirectory, entry.file_name); @@ -424,12 +455,12 @@ const reservePackageDestinations = async ( */ export class AdbProvider { private readonly environment: Readonly>; - readonly #pendingPackageDirectoryCleanup = - new Set(); + readonly #pendingOutputDirectoryCleanup = + new Set(); readonly #activeOperations = new Set>(); #closing = false; #closePromise: Promise | undefined; - #packageCleanupPromise: Promise | undefined; + #outputCleanupPromise: Promise | undefined; constructor(options: AdbProviderOptions = {}) { this.environment = options.environment ?? process.env; @@ -462,13 +493,13 @@ export class AdbProvider { } } - /** Retry removal of package directories retained after failed rollback. */ + /** Retry removal of owned output directories retained after failed cleanup. */ close(): Promise { this.#closing = true; if (this.#closePromise !== undefined) return this.#closePromise; const closing = (async () => { await Promise.all(this.#activeOperations); - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); })().finally(() => { if (this.#closePromise === closing) this.#closePromise = undefined; }); @@ -476,23 +507,23 @@ export class AdbProvider { return this.#closePromise; } - #retryPackageDirectoryCleanup(): Promise { - if (this.#packageCleanupPromise !== undefined) - return this.#packageCleanupPromise; - const cleanup = this.#performPackageDirectoryCleanup().finally(() => { - if (this.#packageCleanupPromise === cleanup) - this.#packageCleanupPromise = undefined; + #retryOutputDirectoryCleanup(): Promise { + if (this.#outputCleanupPromise !== undefined) + return this.#outputCleanupPromise; + const cleanup = this.#performOutputDirectoryCleanup().finally(() => { + if (this.#outputCleanupPromise === cleanup) + this.#outputCleanupPromise = undefined; }); - this.#packageCleanupPromise = cleanup; + this.#outputCleanupPromise = cleanup; return cleanup; } - async #performPackageDirectoryCleanup(): Promise { + async #performOutputDirectoryCleanup(): Promise { const failures: AnalysisError[] = []; - for (const owner of this.#pendingPackageDirectoryCleanup) { + for (const owner of this.#pendingOutputDirectoryCleanup) { try { await owner.remove(); - this.#pendingPackageDirectoryCleanup.delete(owner); + this.#pendingOutputDirectoryCleanup.delete(owner); } catch (cause: unknown) { const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, @@ -500,13 +531,13 @@ export class AdbProvider { { reason: cause instanceof Error ? cause.message : String(cause), }, - { operation: "pull_adb_package", cause }, + { operation: owner.operation, cause }, ); failures.push( analysisErrorWithCleanupFailure( owner.primary, cleanup, - "pull_adb_package", + owner.operation, ), ); } @@ -515,20 +546,22 @@ export class AdbProvider { if (failures.length > 1) throw new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map(({ path }) => path), + [...this.#pendingOutputDirectoryCleanup].map(({ path }) => path), { - reason: - "Failed package output directories remain after cleanup retry", + reason: "Failed ADB output directories remain after cleanup retry", + operations: [...this.#pendingOutputDirectoryCleanup].map( + ({ operation }) => operation, + ), failures: failures.map(({ message, cleanupResources }) => ({ message, resources: [...cleanupResources], })), }, { - operation: "pull_adb_package", + operation: "close_binary", cause: new AggregateError( failures, - "Multiple ADB package rollback cleanups failed", + "Multiple ADB output directory cleanups failed", ), }, ); @@ -546,16 +579,16 @@ export class AdbProvider { }); this.#activeOperations.add(active); try { - if (this.#pendingPackageDirectoryCleanup.size > 0) { + if (this.#pendingOutputDirectoryCleanup.size > 0) { try { - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); } catch (cause: unknown) { return err( cause instanceof AnalysisError ? cause : new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map( + [...this.#pendingOutputDirectoryCleanup].map( ({ path }) => path, ), { @@ -1245,7 +1278,7 @@ export class AdbProvider { if (!directory.ok) return directory; const packageDirectory = join(directory.value, packageName); let packageDirectoryCreated = false; - let packageDirectoryIdentity: PackageDirectoryIdentity | undefined; + let packageDirectoryIdentity: OutputDirectoryIdentity | undefined; try { if ( await lstat(packageDirectory).then( @@ -1275,17 +1308,18 @@ export class AdbProvider { { cause }, ); if (!packageDirectoryCreated) return err(primary); - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), }; - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -1299,12 +1333,13 @@ export class AdbProvider { const failOwnedPackage = async ( primary: AnalysisError, ): Promise> => { - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), @@ -1312,7 +1347,7 @@ export class AdbProvider { try { await owner.remove(); } catch (cause: unknown) { - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -2084,48 +2119,71 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const collected = await runAdb( - operation, - input.serial, - selection.binary, - adbArguments(input.serial, ["bugreport", directory.value]), - { - timeoutMs: BUGREPORT_TIMEOUT_MS, - maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, - }, - signal, - ); - if (!collected.ok) return collected; - const reportedPath = parseBugreportCopiedPath(collected.value.stdout); - if (reportedPath === null) - return err( - new AnalysisProtocolError( - "adb bugreport did not report an archive path; the build may use an unsupported format", - ), - ); + let stagingDirectory: string; + let stagingIdentity: OutputDirectoryIdentity | undefined; + let retainedObservation: AdbAcquiredFilePartialObservation | undefined; + let publishedPath: string | undefined; try { - const digest = await hashAdbFile(reportedPath, operation, signal); - return ok( - createAnalysisExecution( - { - client, - serial: input.serial, - local_path: reportedPath, - bytes: digest.bytes, - sha256: digest.sha256, - adb_reported_path: reportedPath, - }, - provider, - { - limitations: [ - "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", - ], - }, - ), + stagingDirectory = await mkdtemp( + join(directory.value, ".rea-bugreport-"), ); } catch (cause) { - if (cause instanceof AnalysisCancelledError) { - cause.retainPartialObservation({ + return err( + new AnalysisOutputError( + operation, + "The bugreport staging directory could not be created", + { cause }, + ), + ); + } + const collectedResult = await (async (): Promise< + Result + > => { + let reportedPath: string | null = null; + try { + const created = await lstat(stagingDirectory); + if (!created.isDirectory() || created.isSymbolicLink()) + throw new Error( + `The created bugreport staging output is not a regular directory: ${stagingDirectory}`, + ); + stagingIdentity = { + dev: created.dev, + ino: created.ino, + mode: created.mode, + }; + const collected = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["bugreport", stagingDirectory]), + { + timeoutMs: BUGREPORT_TIMEOUT_MS, + maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!collected.ok) return collected; + reportedPath = parseBugreportCopiedPath(collected.value.stdout); + if (reportedPath === null) + return err( + new AnalysisProtocolError( + "adb bugreport did not report an archive path; the build may use an unsupported format", + ), + ); + if (dirname(resolve(reportedPath)) !== stagingDirectory) + return err( + new AnalysisProtocolError( + `adb bugreport reported an archive outside its staging directory: ${reportedPath}`, + ), + ); + const archive = await lstat(reportedPath); + if (!archive.isFile() || archive.isSymbolicLink()) + return err( + new AnalysisProtocolError( + `adb bugreport did not report a regular archive file: ${reportedPath}`, + ), + ); + retainedObservation = { provider_id: "adb", operation, result: { @@ -2135,16 +2193,94 @@ export class AdbProvider { adb_reported_path: reportedPath, digest_status: "unknown", }, - }); - return err(cause); - } - return err( - new AnalysisOutputError( + }; + try { + publishedPath = await publishBugreport(reportedPath, directory.value); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be published: ${reportedPath}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + retainedObservation = { + ...retainedObservation, + result: { ...retainedObservation.result, local_path: publishedPath }, + }; + const digest = await hashAdbFile(publishedPath, operation, signal); + const report = { + client, + serial: input.serial, + local_path: publishedPath, + bytes: digest.bytes, + sha256: digest.sha256, + adb_reported_path: reportedPath, + }; + retainedObservation = { + provider_id: "adb", + operation, + result: report, + }; + return ok( + createAnalysisExecution(report, provider, { + limitations: [ + "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", + ], + }), + ); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) { + if (retainedObservation !== undefined) + cause.retainPartialObservation(retainedObservation); + return err(cause); + } + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + })(); + // A completed acquisition becomes caller evidence even if publication fails. + // Its staging directory must not enter automatic cleanup or cleanup retries. + if (retainedObservation !== undefined && publishedPath === undefined) + return collectedResult; + try { + await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity); + } catch (cause) { + const primary = collectedResult.ok + ? new AnalysisOutputError( + operation, + "The archive was published, but its staging directory could not be removed", + ) + : collectedResult.error; + if (retainedObservation !== undefined) + primary.retainPartialObservation(retainedObservation); + this.#pendingOutputDirectoryCleanup.add({ + operation, + path: stagingDirectory, + identity: stagingIdentity, + primary, + remove: () => + removeOwnedOutputDirectory(stagingDirectory, stagingIdentity), + }); + return err( + analysisErrorWithCleanupFailure( + primary, + new ProviderCleanupError( + ADB_PROVIDER_IDENTITY.id, + [stagingDirectory], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation, cause }, + ), operation, - `The reported bugreport archive could not be read back: ${reportedPath}`, - { cause }, ), ); } + return collectedResult; } } diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index fd956dde98..11198df5e7 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -709,7 +709,7 @@ export type AdbPackagePullPartialObservation = { readonly result: z.infer; }; -/** A completed acquisition whose digest read was cancelled before verification. */ +/** Acquired file facts retained when publication, digest verification or cleanup fails. */ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; @@ -727,13 +727,15 @@ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; readonly operation: "collect_adb_bugreport"; - readonly result: { - readonly client: z.infer< - typeof adbResultSchemas.collect_adb_bugreport - >["client"]; - readonly serial: string; - readonly local_path: string; - readonly adb_reported_path: string; - readonly digest_status: "unknown"; - }; + readonly result: + | z.infer + | { + readonly client: z.infer< + typeof adbResultSchemas.collect_adb_bugreport + >["client"]; + readonly serial: string; + readonly local_path: string; + readonly adb_reported_path: string; + readonly digest_status: "unknown"; + }; }; diff --git a/tests/boundary/providers/adb/adbBugreportPublication.test.ts b/tests/boundary/providers/adb/adbBugreportPublication.test.ts new file mode 100644 index 0000000000..b99799127e --- /dev/null +++ b/tests/boundary/providers/adb/adbBugreportPublication.test.ts @@ -0,0 +1,266 @@ +import { createHash } from "node:crypto"; +import { + copyFile, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; + +import { AdbProvider } from "../../../../src/adb/AdbProvider.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { + execFileBytes, + execFileOutput, +} from "../../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +// Exercise real filesystem publication; the Android process reply is simulated. +vi.mock( + "../../../../src/process/ExecFileOutput.js", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../src/process/ExecFileOutput.js") + >()), + execFileBytes: vi.fn(), + execFileOutput: vi.fn(), + }), +); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyFile: vi.fn(actual.copyFile), + mkdtemp: vi.fn(actual.mkdtemp), + rm: vi.fn(actual.rm), + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +const fixture = async (duringCollection?: (root: string) => Promise) => { + const root = await createTestTempDirectory("rea-adb-bugreport-"); + const binary = join(root, "adb"); + let reportedPath = ""; + vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => { + expect(args).toEqual(["version"]); + return { + stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`, + stderr: "", + }; + }); + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + expect(args.slice(0, 3)).toEqual(["-s", "device", "bugreport"]); + const staging = args[3]!; + reportedPath = join(staging, "bugreport test.zip"); + await writeFile(reportedPath, "bugreport-bytes"); + await duringCollection?.(root); + return { + stdout: Buffer.from(`Bug report copied to ${reportedPath}\n`), + stderr: Buffer.alloc(0), + }; + }); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + return { + root, + provider, + reportedPath: () => reportedPath, + request: { + operation: "collect_adb_bugreport" as const, + input: { serial: "device", output_directory: root }, + }, + }; +}; + +test.each(["before", "during"] as const)( + "preserves a same-name caller archive created %s collection", + async (when) => { + const existing = async (root: string) => { + await writeFile(join(root, "bugreport test.zip"), "existing evidence"); + await writeFile(join(root, "bugreport test (1).zip"), "other evidence"); + }; + const f = await fixture(when === "during" ? existing : undefined); + if (when === "before") await existing(f.root); + const result = await f.provider.execute(f.request); + if (!result.ok) throw result.error; + const localPath = join(f.root, "bugreport test (2).zip"); + expect(result.value.result).toMatchObject({ + local_path: localPath, + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "existing evidence", + ); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "other evidence", + ); + expect(await readFile(localPath, "utf8")).toBe("bugreport-bytes"); + expect((await readdir(f.root)).sort()).toEqual([ + "bugreport test (1).zip", + "bugreport test (2).zip", + "bugreport test.zip", + ]); + await f.provider.close(); + }, +); + +test("retains a completed archive after failed publication and later provider work", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + const publicationFailure = Object.assign(new Error("no space left"), { + code: "ENOSPC", + }); + vi.mocked(copyFile).mockRejectedValueOnce(publicationFailure); + const failed = await f.provider.execute(f.request); + if (failed.ok) throw new Error("expected publication failure"); + const retainedPath = f.reportedPath(); + expect(failed.error._tag).toBe("AnalysisOutputError"); + expect(failed.error.cause).toBe(publicationFailure); + expect( + projectAnalysisError(failed.error).details?.partial_observation, + ).toMatchObject({ + provider_id: "adb", + operation: f.request.operation, + result: { + serial: "device", + local_path: retainedPath, + adb_reported_path: retainedPath, + digest_status: "unknown", + }, + }); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + + // New work and terminal cleanup must not reclaim the retained acquisition. + const next = await f.provider.execute(f.request); + if (!next.ok) throw next.error; + expect(next.value.result).toMatchObject({ + local_path: join(f.root, "bugreport test (1).zip"), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + await f.provider.close(); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect((await readdir(f.root)).sort()).toEqual( + [ + basename(dirname(retainedPath)), + "bugreport test.zip", + "bugreport test (1).zip", + ].sort(), + ); +}); + +test("retains final and producer paths when digest verification is cancelled", async () => { + const f = await fixture(); + const controller = new AbortController(); + const actualCopy = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(copyFile).mockImplementationOnce(async (...args) => { + await actualCopy.copyFile(...args); + controller.abort(); + }); + const result = await f.provider.execute(f.request, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + digest_status: "unknown", + }, + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("returns staging allocation failures without invoking device collection", async () => { + const f = await fixture(); + vi.mocked(mkdtemp).mockRejectedValueOnce(new Error("staging denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected output failure"); + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(execFileBytes).not.toHaveBeenCalled(); + await f.provider.close(); +}); + +test("retains published evidence and retries failed staging cleanup", async () => { + const f = await fixture(); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error.cleanupResources).toContain(dirname(f.reportedPath())); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }, + }); + await f.provider.close(); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); +}); + +test("refuses an archive reported outside the owned staging directory", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from(`Bug report copied to ${existing}\n`), + stderr: Buffer.alloc(0), + }); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol refusal"); + expect(result.error._tag).toBe("AnalysisProtocolError"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("preserves the primary protocol failure when staging cleanup also fails", async () => { + const f = await fixture(); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from("unsupported completion format\n"), + stderr: Buffer.alloc(0), + }); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol and cleanup failure"); + expect(result.error.cause).toMatchObject({ + errors: [ + { + _tag: "AnalysisProtocolError", + message: expect.stringContaining("did not report an archive path"), + }, + expect.any(Error), + ], + }); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toHaveLength(1); + await f.provider.close(); + expect(await readdir(f.root)).toEqual([]); +}); From 8e004522f232ec698c77536235853088a851e6b5 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:42:06 +0800 Subject: [PATCH 18/93] docs: define performance priorities for analysis workflows (#2245) Set agent tokens and context first, analysis time second, and CPU and memory third while preserving correctness, Evidence and existing contracts. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- AGENTS.md | 25 +++++++++++++++++++++++++ CONTRIBUTING.md | 5 +++++ docs/tool-design.md | 10 ++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fb27e7659..7849a33eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,31 @@ Parse unknown boundary values and model expected failures with the tagged error Validate advertised JSON Schemas after SDK conversion against their declared dialect and actual clients. Bind handlers to named contracts with exact types; catalog ordering must never select an operation or schema. +## Performance + +Design analysis workflows and agent interfaces for efficiency from the first +implementation. Correctness, useful answers, Evidence provenance, caller choices, +compatibility and safe resource bounds remain required. + +Optimize in this order: + +1. Agent tokens and context. Return the smallest sufficient answer with + necessary provenance, coverage and limitations. Use task selection, + summaries, paging and retained Evidence where supported; honor requested + detail and keep complete Evidence accessible. Count the whole task, + including schemas, extra calls and retries. +2. Analysis time. Reuse validated results and shared work. Cache keys must + cover result-affecting inputs; define invalidation and lifetime. Validate + freshness before reporting cached live state as current. +3. CPU and memory. Avoid unnecessary work and copies; bound concurrency, + retained state and serialization growth. + +For changes affecting these costs, measure representative completed tasks and +compare equivalent workflows before and after where available. Report token +usage, elapsed time, CPU time and peak process-family memory. Separate cold +and warm runs, label estimates, and state unmeasured costs. Fewer bytes alone +do not establish fewer tokens or faster analysis. + ## Development Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain. Cursor Cloud may place older Node.js first on `PATH`; environment setup installs the pinned toolchain under `/usr/local/bin`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519e83cc95..a8d5f3635a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,11 @@ targets; optional adapter failures must preserve successful peers. Run `npm run verify:test-discovery` after adding or moving tests. Resolved import boundaries are checked by `npm run verify:module-boundaries`, included in lint. +For analysis workflows and agent interfaces, follow the +[performance priorities](AGENTS.md#performance). Explain the selected output +scope, reuse/cache decisions, and relevant before/after measurements when these +costs change. + When adding or changing an MCP tool, follow the [tool design guide](docs/tool-design.md) and preserve the canonical contracts and generated catalog. ## Contribution scope diff --git a/docs/tool-design.md b/docs/tool-design.md index a4dd155f06..6117ff6d03 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -48,10 +48,12 @@ satisfy. A plan without execution does not establish runtime behavior. - Use strict inputs with explicit target identity and required fields. Preserve meaningful target, action, capture, and output choices; remove ignored options and repeated permission declarations. -- Return complete evidence inline, with artifact/provider identity, source - locations, Evidence references, and relevant confidence and limitations. - Missing coverage is unknown, not absence. Avoid extra lookups solely to - understand the answer or its provenance. +- Return the requested facts inline with artifact/provider identity, source + locations, Evidence references, coverage, and relevant confidence and + limitations. Use supported selection, summaries or views when they answer + the question; preserve access to complete source Evidence and honor existing + contracts and requested detail. Missing coverage is unknown, not absence. + Avoid extra lookups solely to understand the answer or its provenance. - Keep observed, derived, and inferred relationships distinct. Preserve unresolved edges and partial provider facts instead of requiring every provider to supply the same metadata. From 59be866719e8fe8ec060515044d427a3b0a57649 Mon Sep 17 00:00:00 2001 From: Ha1baraA1 <243105435+Ha1baraA11@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:45:23 +0800 Subject: [PATCH 19/93] fix(ghidra): encode Java probe output as UTF-8 (#2206) * fix(ghidra): encode Java probe output as UTF-8 * fix(ghidra): preserve Unicode Java homes across supported JDKs * test(ghidra): verify snapshot rejection without matching prose --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + scripts/ci/scopes.mjs | 1 + .../lib/real-ghidra-snapshot-lifecycle.mjs | 9 +- src/ghidra/GhidraInstallation.test.ts | 5 +- src/ghidra/GhidraInstallation.ts | 11 ++- .../filesystem/ghidraJavaProbe.test.ts | 82 +++++++++++++++++++ .../analysis-sessions/binarySession.test.ts | 8 +- 7 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 tests/boundary/filesystem/ghidraJavaProbe.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60920f0de5..ba8c026177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,6 +504,7 @@ jobs: tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts tests/conformance/providers/ghidra/windowsCapabilities.test.ts src/ghidra/GhidraInstallation.test.ts + tests/boundary/filesystem/ghidraJavaProbe.test.ts tests/boundary/providers/ghidra/ghidraTransport.test.ts tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts tests/boundary/providers/ghidra/ghidraLauncher.test.ts diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 728cedc570..497dfeed0e 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -64,6 +64,7 @@ export const SCOPES = { "tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts", "tests/conformance/providers/ghidra/windowsCapabilities.test.ts", "src/ghidra/GhidraInstallation.test.ts", + "tests/boundary/filesystem/ghidraJavaProbe.test.ts", "tests/boundary/providers/ghidra/ghidraTransport.test.ts", "tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts", "tests/boundary/providers/ghidra/ghidraLauncher.test.ts", diff --git a/scripts/lib/real-ghidra-snapshot-lifecycle.mjs b/scripts/lib/real-ghidra-snapshot-lifecycle.mjs index 4a854c7314..e359f22ac8 100644 --- a/scripts/lib/real-ghidra-snapshot-lifecycle.mjs +++ b/scripts/lib/real-ghidra-snapshot-lifecycle.mjs @@ -36,16 +36,11 @@ export async function verifyGhidraSnapshotLifecycle( ); const error = requireMcpToolError(reply); assert.equal( - error?.code, + error.code, "evidence_integrity_mismatch", JSON.stringify(reply), ); - assert.match(error.message, /after analysis metadata mutations/u); - assert.match( - error.remediation.action, - /close.*without saving a snapshot/iu, - ); - assert.match(error.remediation.action, /export_evidence_bundle/u); + assert.equal(error.retryable, false); rejectedCalls++; }; try { diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 059915d99c..185c719738 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -22,6 +22,7 @@ const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC"; const WINDOWS_PROPERTIES = `${WINDOWS_INSTALL}\\Ghidra\\application.properties`; const WINDOWS_HEADLESS = `${WINDOWS_INSTALL}\\support\\analyzeHeadless.bat`; const WINDOWS_DECOMPILER = `${WINDOWS_INSTALL}\\Ghidra\\Features\\Decompiler\\os\\win_x86_64\\decompile.exe`; + const JAVA: GhidraJavaObservation = { version: "21.0.11", major: SUPPORTED_GHIDRA_JAVA_MAJOR, @@ -43,7 +44,9 @@ const host = ( ...overrides, }); -afterEach(() => vi.unstubAllEnvs()); +afterEach(() => { + vi.unstubAllEnvs(); +}); it("probes the selected Java environment without reading conflicting ambient settings", () => { vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient"); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 8d2043e6bf..da762cb797 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -734,7 +734,16 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ probeJava(command, environment) { const observed = spawnSync( command, - ["-XX:-UsePerfData", "-XshowSettings:properties", "-version"], + [ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + // JDK 17/18 use the legacy names; JDK 19+ use the names above. + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + "-XX:-UsePerfData", + "-XshowSettings:properties", + "-version", + ], { encoding: "utf8", env: environment, diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts new file mode 100644 index 0000000000..82ed00ef76 --- /dev/null +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +import { inspectGhidraInstallation } from "../../../src/ghidra/GhidraInstallation.js"; +import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraInstallationPolicy.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() })); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawnSync: mockSpawnSync, +})); + +afterEach(() => mockSpawnSync.mockReset()); + +it.each([ + { major: 17, fullJdk: true }, + { major: 21, fullJdk: true }, + { major: 21, fullJdk: false }, +])( + "preserves the reported Unicode Java home and classifies JDK $major (full JDK: $fullJdk)", + async ({ major, fullJdk }) => { + const root = await createTestTempDirectory("rea-java-probe-"); + const configuredHome = join(root, "selected-jdk"); + const reportedHome = join(root, "Réa逆向", `JDK ${String(major)}`); + const installDir = join(root, "ghidra"); + await mkdir(join(installDir, "Ghidra"), { recursive: true }); + await writeFile( + join(installDir, "Ghidra", "application.properties"), + `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, + ); + if (fullJdk) { + await mkdir(join(reportedHome, "bin"), { recursive: true }); + await writeFile( + join( + reportedHome, + "bin", + process.platform === "win32" ? "javac.exe" : "javac", + ), + "compiler presence fixture", + { mode: 0o755 }, + ); + } + const version = `${String(major)}.0.11`; + mockSpawnSync.mockReturnValue({ + stdout: "", + stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n`, + status: 0, + }); + + const inspection = inspectGhidraInstallation({ + environment: {}, + installDir, + javaHome: configuredHome, + }); + + expect(inspection.javaHome).toBe(reportedHome); + expect(inspection.javaVersion).toBe(version); + expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject( + { + status: fullJdk ? "passed" : "failed", + detail: expect.stringContaining(fullJdk ? "; JDK;" : "; runtime only;"), + }, + ); + expect(mockSpawnSync).toHaveBeenCalledExactlyOnceWith( + join( + configuredHome, + "bin", + process.platform === "win32" ? "java.exe" : "java", + ), + expect.arrayContaining([ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + ]), + expect.objectContaining({ encoding: "utf8" }), + ); + }, +); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 27e591a17d..813b90004b 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -374,9 +374,7 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false, error: { - message: expect.stringContaining( - "Analysis snapshots are unavailable because analysis metadata may have changed", - ), + _tag: "EvidenceIntegrityError", }, }); expect((await session.open(target)).ok).toBe(true); @@ -384,9 +382,7 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({ ok: false, error: { - userMessage: expect.stringContaining( - "Close the active target without saving a snapshot", - ), + _tag: "EvidenceIntegrityError", }, }); expect(session.exportEvidenceBundle().records).toContainEqual(relocated); From 8cbd57cafaf1cfacbe832bae593589cf211442ef Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:51:42 +0800 Subject: [PATCH 20/93] fix(website): prioritize getting started across language editions --- scripts/website.py | 2 +- website/locales/ar/messages.po | 371 +++++++++--------- website/locales/de/messages.po | 371 +++++++++--------- website/locales/es/messages.po | 371 +++++++++--------- website/locales/fa/messages.po | 371 +++++++++--------- website/locales/fr/messages.po | 371 +++++++++--------- website/locales/id/messages.po | 371 +++++++++--------- website/locales/it/messages.po | 371 +++++++++--------- website/locales/ja/messages.po | 371 +++++++++--------- website/locales/ko/messages.po | 371 +++++++++--------- website/locales/messages.pot | 318 +++++++-------- website/locales/pl/messages.po | 371 +++++++++--------- website/locales/pt-BR/messages.po | 371 +++++++++--------- website/locales/ru/messages.po | 371 +++++++++--------- website/locales/th/messages.po | 371 +++++++++--------- website/locales/tr/messages.po | 371 +++++++++--------- website/locales/uk/messages.po | 371 +++++++++--------- website/locales/vi/messages.po | 371 +++++++++--------- website/locales/zh-CN/messages.po | 371 +++++++++--------- website/locales/zh-TW/messages.po | 371 +++++++++--------- website/public/assets/styles.css | 30 +- website/public/blog/index.html | 2 +- .../blog/touhou-reconstruction/index.html | 2 +- website/public/examples/dino-lab/index.html | 2 +- website/public/faq/index.html | 2 +- website/public/first-investigation/index.html | 2 +- website/public/get-started/index.html | 2 +- website/public/guides/browser/index.html | 2 +- website/public/guides/index.html | 2 +- website/public/guides/javascript/index.html | 2 +- website/public/guides/native/index.html | 2 +- website/public/index.html | 9 +- website/public/showcase/aegis/index.html | 2 +- website/public/showcase/ctf/index.html | 2 +- website/public/showcase/dx-ball/index.html | 2 +- website/public/showcase/index.html | 2 +- website/public/showcase/notion/index.html | 2 +- website/public/showcase/th04/index.html | 2 +- 38 files changed, 3633 insertions(+), 3436 deletions(-) diff --git a/scripts/website.py b/scripts/website.py index 7e20f53a2f..a3bd44333c 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -16,9 +16,9 @@ from urllib.parse import quote, urlsplit SITE_ORIGIN = "https://rea.tools" NOTES_FIXTURES = {"examples/notes-electron/index.html", "examples/notes-web/index.html"} NAVIGATION = ( + ("Get started", "get-started/"), ("Showcases", "showcase/"), ("Blog", "blog/"), - ("Get started", "get-started/"), ("Guides", "guides/"), ("FAQ", "faq/"), ) diff --git a/website/locales/ar/messages.po b/website/locales/ar/messages.po index 1989164ea7..4c8264a50e 100644 --- a/website/locales/ar/messages.po +++ b/website/locales/ar/messages.po @@ -73,7 +73,7 @@ msgstr "بواسطة N0zoM1z0 * 10 أكتوبر 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5848,7 +5848,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "في هذه الصفحة" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "إعداد REA" @@ -5946,7 +5946,7 @@ msgstr "يوفّر REA لمساعد البرمجة لديك أدوات لفحص #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5954,13 +5954,13 @@ msgstr "تعرف بالفعل إعادة? {ph0}تخطي إلى أدلة التح #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "تريد أن تذهب أعمق? {ph0}اقرأ المدونة Blog{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "انسخ هذا الطلب إلى مساعد البرمجة لديك:" @@ -5973,38 +5973,38 @@ msgstr "انسخ هذا الطلب إلى مساعد البرمجة لديك:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مساعدك البرمجي" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "نسخ" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "أو نفّذ هذا الأمر في الطرفية:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "راجع خطة الإعداد ووافق عليها، ثم أعد تشغيل المساعد." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "ما هي الهندسة العكسية؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6017,7 +6017,7 @@ msgstr "فهم طريقة عمل البرنامج من خلال {ph0}فحص ال #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6029,7 +6029,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6037,7 +6037,7 @@ msgstr "قبل REA · تبحث {ph0}بنفسك{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6045,7 +6045,7 @@ msgstr "{ph0}01{ph1} فك رموز الفروع" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6053,7 +6053,7 @@ msgstr "{ph0}02{ph1} تتبع المكالمات" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6063,20 +6063,20 @@ msgstr "{ph0}03{ph1} استعادة القاعدة" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}الأصل · 64 * تعليمات مختارة{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * أي فرع?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6085,29 +6085,29 @@ msgid "" msgstr "" "“{ph0}0/5 ب? 0 * 5 ج?{ph1} اثنين يقفز إلى نفس المكان. التي مشغلي هذه?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "ابحث عن معرفات الأوامر ، ثم قم بتسمية الفرع." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "تقسيم ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "ضرب ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · ماذا تفعل الدوال المستدعاة؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6117,24 +6117,24 @@ msgstr "" "\"100 واضح. المكالمات هي مجرد عناوين. {ph0}أي واحد يقسم? القيمة التي يذهب " "في?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "افحص وظائف المساعد وتتبع القيم المخزنة." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "تقسيم" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * ما هي القاعدة?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6144,19 +6144,19 @@ msgstr "" "\"يتضاعف هذا المسار من خلال المدخلات الأخرى. لذا {ph0}10 ٪ تعني 10 ٪ من " "200{ph1} بعد +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "قم بتسمية المعاملات وتحقق من كلا مساري المشغل." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "منطق الخبراء التوضيحي" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6167,20 +6167,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "مع REA · تسأل {ph0}مساعدك{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "طلب واحد فقط." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6194,49 +6194,49 @@ msgstr "اسأل المساعد {ph0}بكلمات بسيطة.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال موجه * آلة حاسبة" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "مثال على الإجابة * بناء على النتائج" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "بعد + ، يأخذ زر ٪ {ph0}نسبة مئوية من الرقم الأول.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "ما يوفّره REA" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "تعليمات المعالج ، رمز فك والمكالمات." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "ما يشرحه المساعد" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "أي فرع ينطبق ، ما يحسب وكيفية إعادة بنائه." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6248,7 +6248,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6258,37 +6258,37 @@ msgstr "" "{ph0}دعونا نحاول مثالين.{ph1} تغيير سرعة اللعبة ، ثم العودة إلى آلة حاسبة " "وإعادة بناء زر ٪ لها." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "مثال 01 · Chromeلعبة الديناصورات" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "لماذا يصبح الديناصور أسرع؟" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "الهدف" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "إعادة بناء اللعبة مع {ph0}سرعة قابلة للتعديل.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "لماذا عكس هندسة ذلك?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6298,47 +6298,47 @@ msgstr "" "لإعادة إنتاج تسارعها ، نحتاج إلى {ph0}القاعدة في لعبة الجري{ph1}: مدى سرعة " "يبدأ ، وكم يزيد وعندما يتوقف." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "جرّب اللعبة المعاد بناؤها" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "العب" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "اقفز ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "ابدأ من جديد" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "السرعة" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "استخدم قاعدة التسارع المستردة" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "القاعدة الأصلية: ابدأ من 6 ، ثم زد نحو 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "حرك شريط التمرير لضبط السرعة الخاصة بك. الفضاء أو القفز يزيل الصبار." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6346,14 +6346,14 @@ msgid "" msgstr "" "تمكين JavaScript للعب إعادة الإعمار. يظل الرمز والشرح أعلاه قابلا للقراءة." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "ما أعاده REA" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6371,7 +6371,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6383,7 +6383,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال موجه * لعبة د #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6397,7 +6397,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6409,7 +6409,7 @@ msgstr "الهدف الذي تم فحصه: {ph0}وايو Chromium- مستمد م #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6419,12 +6419,12 @@ msgstr "" "{ph0}تبدأ في 6.{ph1} إضافة {ph2}0.001{ph3} كل تحديث دون تصادم ، في حين أن " "السرعة أقل {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "افتح مختبر السرعة →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "ماذا يفعل كل جزء" @@ -6435,7 +6435,7 @@ msgstr "ماذا يفعل كل جزء" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6451,7 +6451,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6468,7 +6468,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6478,13 +6478,13 @@ msgstr "" "{ph0}03 * أنت{ph1}{ph2}تغيير السرعة واللعب{ph3}{ph4}جرب التسارع الأصلي ، ثم " "اختر وتيرتك الخاصة.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "راجع البرنامج النصي والشيكات وكيفية تجربة التحليل" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6494,7 +6494,7 @@ msgstr "" "REA تفقد إصدار متصفح هتب من خلال اتصال التصحيح المحلي وعاد تحميلها {ph0}، " "بما في ذلك مصدرها وهضمها." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6507,7 +6507,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6523,7 +6523,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6540,26 +6540,26 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "{ph0}فحص رمز السرعة{ph1} · {ph2}Chromium الكتاب * رخصة بسد{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "مثال 02 · Windows آلة حاسبة" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "أعد بناء زر % في الآلة الحاسبة." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6568,7 +6568,7 @@ msgstr "بناء آلة حاسبة صغيرة أن يعالج {ph0}كلا + و c #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6578,29 +6578,29 @@ msgstr "" "يستخدم نفس الزر {ph0}قواعد مختلفة بعد + و ×.{ph1} نقوم بفحص التطبيق المثبت " "للعثور على الرقم الذي يتم تطبيق النسبة عليه." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "جرب الحساب" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "إعادة ضبط" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10 ٪ من 200 = 20. ثم 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "توضيح تفاعلي لقاعدة النسبة المئوية التي تم فحصها." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6612,7 +6612,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6626,7 +6626,7 @@ msgstr "لهذا المثال, {ph0} و {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6640,7 +6640,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال موجه * إعادة #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6651,7 +6651,7 @@ msgstr "" "{ph0}›{ph1}{ph2}استخدام REA لتفقد Windows زر ٪ آلة حاسبة. استعادة القواعد " "بعد + و × ، ثم بناء آلة حاسبة صغيرة تستخدم على حد سواء.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "مع +" @@ -6659,13 +6659,13 @@ msgstr "مع +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}خذ 10٪ من الرقم الأول.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "مع ×" @@ -6673,13 +6673,13 @@ msgstr "مع ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}تحويل 10 ٪ إلى 0.1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "من التطبيق المثبت إلى زر ٪ العمل" @@ -6691,7 +6691,7 @@ msgstr "من التطبيق المثبت إلى زر ٪ العمل" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6708,7 +6708,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6719,12 +6719,12 @@ msgstr "" "{ph0}03 * وكيلك يفسر ذلك{ph1}{ph2}شرح وإعادة إنتاج القواعد{ph3}{ph4}تحقق من " "الفرع مع مصدر مايكروسوفت ، ثم حاول + و × أعلاه.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "انظر الرمز الحقيقي وكيف تم التحقق من الإجابة" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6744,7 +6744,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6754,17 +6754,17 @@ msgstr "" "{ph0}النسبة المئوية للتنفيذ{ph1} · {ph2}حالات اختبار مايكروسوفت{ph3} · " "{ph4}دليل الإعداد الأصلي{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "التحقيق الأول الخاص بك" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "حاول REA على تطبيق صغير." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6773,12 +6773,12 @@ msgstr "" "تحميل ملاحظاتنا سبيل المثال ، تتبع لها CSV تصدير ، وتحقق من إدخال واحد تم " "تغييره." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "جرب المثال الإرشادي" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "هل تريد التعمّق أكثر؟" @@ -6787,7 +6787,7 @@ msgstr "هل تريد التعمّق أكثر؟" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6806,13 +6806,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال موجه * الموقع" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "نعم ، هذا واحد." @@ -6825,7 +6825,7 @@ msgstr "نعم ، هذا واحد." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6838,7 +6838,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال موجه * إعادة #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6851,23 +6851,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}انظر ال DX-Ball دراسة حالة case{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "لخطوات الإعداد والتحليل ، اختر هدفك:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "برامج كود الآلة" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -6876,17 +6876,17 @@ msgstr "" "فحص الوظائف والسلاسل والمراجع وعلاقات استدعاء الوظائف في الملفات التنفيذية " "والمكتبات." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "تحليل دليل برامج كود الآلة" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -6895,17 +6895,17 @@ msgstr "" "وحدات الخريطة ، والطرق ، والتصنيف الدولي للبراءات والتبعيات الأصلية من مجلد " "التطبيق أو ASAR أرشيف." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "سير عمل التطبيق" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "نشاط المتصفح ووقت التشغيل" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -6913,12 +6913,12 @@ msgid "" msgstr "" "التقط المتصفح المحدد أو نشاط العملية ، ثم قارن النتائج عبر عمليات التشغيل." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "دليل مراقبة المتصفح" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "لديك أسئلة؟" @@ -6929,7 +6929,7 @@ msgstr "لديك أسئلة؟" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6939,12 +6939,12 @@ msgstr "" "{ph0}قراءة التعليمات{ph1}, {ph2}الدردشة معنا على Discord{ph3}، أو {ph4}افتح " "أ GitHub القضية{ph5} لالبق وطلبات الميزة." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "انضم إلى المجتمع" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6967,27 +6967,27 @@ msgstr "" "تطبيقات الهندسة العكسية والثنائيات وسلوك المتصفح مع مساعدك البرمجي. اتبع " "أدلة واضحة وجرب أمثلة تفاعلية مع REA." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "نسخ REA موجه التثبيت" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "نسخ أمر الإعداد" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "اتبع تحليل الآلة الحاسبة اليدوية" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "نسخة حاسبة النسبة المئوية السؤال" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -6995,32 +6995,32 @@ msgid "" msgstr "" "ديناصور لعبة صغيرة. استخدم زر القفز أو اضغط على الفضاء أثناء تركيز اللعبة." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "نسخة ديناصور تحليل موجه" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "اختر الحساب" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "نسخ حاسبة تحليل موجه" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "نسخ موجه مشروع موقع الويب" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "نسخة لعبة إعادة الإعمار موجه" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "العودة للأعلى" @@ -11383,26 +11383,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "نفس التباعد: 256 1 16 = 16 وحدة زاوية = 22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "أمثلة عملية" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11420,8 +11400,8 @@ msgstr "أمثلة عملية" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "المدونة" +msgid "Showcases" +msgstr "أمثلة عملية" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11440,6 +11420,26 @@ msgstr "المدونة" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "المدونة" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "ابدأ هنا" @@ -11472,7 +11472,7 @@ msgstr "الأدلة" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11492,7 +11492,7 @@ msgstr "الكود المصدري" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11512,7 +11512,7 @@ msgstr "رخصة MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11532,7 +11532,7 @@ msgstr "انضم إلى المجتمع" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11643,37 +11643,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "شاهد كيف يعمل REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "دليل الإعداد" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "يعرض →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "الإعدادات المحددة من البرنامج النصي الذي تم فحصه" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "ملخص مقروء لمعالج ٪" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * تعليمات أصلية مختارة" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "معرفات المشغل * مصدر مايكروسوفت" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "اتبع @رياتولس على س" @@ -12080,7 +12075,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12098,7 +12093,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12324,7 +12319,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12337,7 +12332,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12350,7 +12345,7 @@ msgstr "مثلاً: {ph0}لماذا تعطي الآلة الحاسبة 220 عن #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12361,7 +12356,7 @@ msgstr "" "220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12370,7 +12365,7 @@ msgstr "REA * تحميل {ph0} * خطوط مختارة" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12388,7 +12383,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12446,6 +12441,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "الرئيسية/{ph0} * استقبال الحافظة" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "ابدأ هنا {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "دليل الإعداد" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/de/messages.po b/website/locales/de/messages.po index 6dc80be29f..3a0ab13046 100644 --- a/website/locales/de/messages.po +++ b/website/locales/de/messages.po @@ -74,7 +74,7 @@ msgstr "Von N0zoM1z0 · 10. Oktober 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6106,7 +6106,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Auf dieser Seite" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Einrichten von REA" @@ -6206,7 +6206,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6214,13 +6214,13 @@ msgstr "Kennen Sie RE schon? {ph0}Zu den Analysehandbüchern springen →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Willst du tiefer gehen? {ph0}Lesen Sie den Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Kopiere diesen Prompt in deinen Coding-Agent:" @@ -6233,40 +6233,40 @@ msgstr "Kopiere diesen Prompt in deinen Coding-Agent:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ihr Codierungsagent" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Kopieren" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Oder führe diesen Befehl im Terminal aus:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "" "Prüfe und bestätige die geplanten Änderungen. Starte danach deinen Agent " "neu." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Was ist Reverse Engineering?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6281,7 +6281,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6293,7 +6293,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6301,7 +6301,7 @@ msgstr "Vor REA · Du untersuchst es {ph0}selbst{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6309,7 +6309,7 @@ msgstr "{ph0}01{ph1} Zweige dekodieren" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6317,7 +6317,7 @@ msgstr "{ph0}02{ph1} Anrufe verfolgen" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6327,20 +6327,20 @@ msgstr "{ph0}03{ph1} Wiederherstellen der Regel" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Original x64 * ausgewählte Anleitung{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * Welche Branche?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6350,29 +6350,29 @@ msgstr "" "“{ph0}0x5b? 0x5 Grad?{ph1} Zwei Sprünge an die gleiche Stelle. Welche " "Betreiber sind das?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Suchen Sie nach den Befehls-IDs und beschriften Sie den Zweig." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Teilen ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Multiplizieren ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Was machen die aufgerufenen Funktionen?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6382,26 +6382,26 @@ msgstr "" "“100 ist klar. Die Anrufe sind nur Adressen. {ph0}Welcher teilt sich? " "Welcher Wert geht ein?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "" "Überprüfen Sie die Hilfsfunktionen und verfolgen Sie die gespeicherten " "Werte." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Teilen" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 · Was ist die Regel?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6411,19 +6411,19 @@ msgstr "" "“Dieser Pfad multipliziert sich mit der anderen Eingabe. So {ph0}10% " "bedeutet 10% von 200{ph1} nach +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Benennen Sie die Operanden und überprüfen Sie beide Operatorpfade." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Anschauliche Argumentation von Experten" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6435,20 +6435,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Mit REA · Du fragst {ph0}deinen Agent{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Ein einziger Prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6462,7 +6462,7 @@ msgstr "Frag deinen Agent {ph0}in ganz normalen Worten.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" @@ -6470,43 +6470,43 @@ msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Beispiel Eingabeaufforderung * " "Rechner" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Beispielantwort * basierend auf den Ergebnissen" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "Nach + nimmt die% -Taste {ph0}ein Prozentsatz der ersten Zahl.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA liefert" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Die Anweisungen des Handlers, dekompilierter Code und Aufrufe." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Dein Agent erklärt" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Welcher Zweig gilt, was er berechnet und wie er neu erstellt wird." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6519,7 +6519,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6530,37 +6530,37 @@ msgstr "" " Spiels, kehre dann zum Taschenrechner zurück und baue die%-Schaltfläche neu" " auf." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Beispiel 01 * Dinosaurier-Spiel von Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Warum wird der Dinosaurier schneller?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Ziel" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Baue das Spiel mit neu auf {ph0}einstellbare Geschwindigkeit.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Warum es zurückentwickeln?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6571,51 +6571,51 @@ msgstr "" "laufenden Spiel{ph1}: wie schnell es beginnt, wie stark es zunimmt und wann " "es aufhört." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Den Nachbau spielen" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Spielen" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Springen ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Neustart" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Geschwindigkeit" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Verwenden Sie die wiederhergestellte Beschleunigungsregel" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "" "Ursprüngliche Regel: Beginnen Sie bei 6 und erhöhen Sie sich dann in " "Richtung 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Bewegen Sie den Schieberegler, um Ihre eigene Geschwindigkeit einzustellen. " "Leertaste oder Sprung löscht einen Kaktus." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6624,14 +6624,14 @@ msgstr "" "Aktivieren Sie JavaScript, um die Rekonstruktion abzuspielen. Der Code und " "die obige Erklärung bleiben lesbar." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA hat zurückgegeben" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6650,7 +6650,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6663,7 +6663,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6678,7 +6678,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6692,7 +6692,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6702,12 +6702,12 @@ msgstr "" "{ph0}Beginnen Sie bei 6.{ph1} Hinzufügen {ph2}0.001{ph3} jedes Update ohne " "Kollision, während die Geschwindigkeit darunter liegt {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Öffnen Sie das Geschwindigkeitslabor →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Was jeder Teil tut" @@ -6718,7 +6718,7 @@ msgstr "Was jeder Teil tut" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6735,7 +6735,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6752,7 +6752,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6763,7 +6763,7 @@ msgstr "" "Sie{ph3}{ph4}Probieren Sie die ursprüngliche Beschleunigung aus und wählen " "Sie dann Ihr eigenes Tempo.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "" @@ -6771,7 +6771,7 @@ msgstr "" "Analyse an" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6782,7 +6782,7 @@ msgstr "" "Verbindung und gab das geladene zurück {ph0}, einschließlich seiner Quelle " "und Verdauung." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6797,7 +6797,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6814,7 +6814,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6832,7 +6832,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6841,19 +6841,19 @@ msgstr "" "{ph0}Geprüfter Geschwindigkeitscode{ph1} · {ph2} Chromium Autoren * BSD-" "Lizenz{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Beispiel 02 * Windows Rechner" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Die %-Taste des Rechners nachbauen." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6864,7 +6864,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6875,29 +6875,29 @@ msgstr "" "×.{ph1} Wir überprüfen die installierte App, um herauszufinden, auf welche " "Zahl der Prozentsatz angewendet wird." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Probieren Sie die Berechnung aus" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Zurücksetzen" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% von 200 = 20. Dann ist 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Interaktive Darstellung der geprüften Prozentregel." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6910,7 +6910,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6924,7 +6924,7 @@ msgstr "Für dieses Beispiel, {ph0} und {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6940,7 +6940,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6953,7 +6953,7 @@ msgstr "" "erstellen Sie dann einen kleinen Taschenrechner, der beide " "verwendet.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Mit +" @@ -6961,13 +6961,13 @@ msgstr "Mit +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Nimm 10% der ersten Zahl.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Mit ×" @@ -6975,13 +6975,13 @@ msgstr "Mit ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Verwandle 10% in 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Von der installierten App zu einer funktionierenden% -Schaltfläche" @@ -6993,7 +6993,7 @@ msgstr "Von der installierten App zu einer funktionierenden% -Schaltfläche" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7011,7 +7011,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7023,12 +7023,12 @@ msgstr "" " die Regeln{ph3}{ph4}Überprüfen Sie den Zweig mit der Quelle von Microsoft " "und versuchen Sie dann oben + und ×.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Sehen Sie sich den echten Code an und wie die Antwort überprüft wurde" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -7049,7 +7049,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7059,17 +7059,17 @@ msgstr "" "{ph0}Prozentuale Umsetzung{ph1} · {ph2}Testfälle von Microsoft{ph3} · " "{ph4}Anleitung zur nativen Einrichtung{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Ihre erste Untersuchung" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Probieren Sie REA mit einer kleinen App aus." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -7078,12 +7078,12 @@ msgstr "" "Laden Sie unser Notizbeispiel herunter, verfolgen Sie den Export von CSV und" " überprüfen Sie eine geänderte Eingabe." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Probieren Sie das geführte Beispiel aus" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Möchtest du tiefer einsteigen?" @@ -7092,7 +7092,7 @@ msgstr "Möchtest du tiefer einsteigen?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7112,7 +7112,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" @@ -7120,7 +7120,7 @@ msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Beispiel Eingabeaufforderung * " "Website" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Ja, das hier." @@ -7133,7 +7133,7 @@ msgstr "Ja, das hier." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7148,7 +7148,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7162,23 +7162,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Siehe die Fallstudie DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Wählen Sie für Einrichtungs- und Analyseschritte Ihr Ziel aus:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Maschinencode-Programme" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7187,17 +7187,17 @@ msgstr "" "Überprüfen Sie Funktionen, Zeichenfolgen, Referenzen und " "Funktionsaufrufbeziehungen in ausführbaren Dateien und Bibliotheken." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Handbuch zur Analyse von Maschinencode-Programmen" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "Modell: JavaScript und Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7206,17 +7206,17 @@ msgstr "" "Ordnen Sie Module, Routen, IPC und native Abhängigkeiten aus einem " "Anwendungsordner oder einem ASAR-Archiv zu." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Anwendungs-Workflows" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Browser- & Laufzeit-Aktivität" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7225,12 +7225,12 @@ msgstr "" "Erfassen Sie ausgewählte Browser- oder Prozessaktivitäten und vergleichen " "Sie dann die Ergebnisse über Läufe hinweg." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Anleitung zur Browserbeobachtung" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Noch Fragen?" @@ -7241,7 +7241,7 @@ msgstr "Noch Fragen?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7252,12 +7252,12 @@ msgstr "" "oder {ph4}öffnen Sie eine GitHub-Ausgabe{ph5} für Fehler und " "Funktionswünsche." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Zur Community" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7281,27 +7281,27 @@ msgstr "" "Codierungsagenten. Befolgen Sie klare Anleitungen und probieren Sie " "interaktive Beispiele mit REA aus." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Installationsaufforderung für REA kopieren" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Setup-Befehl kopieren" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Befolgen Sie die manuelle Taschenrechneranalyse" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Kopierrechner Prozentfrage" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7310,32 +7310,32 @@ msgstr "" "Dinosaurier-Minispiel. Verwenden Sie die Sprungtaste oder drücken Sie die " "Leertaste, während das Spiel fokussiert ist." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Aufforderung zur Dinosaurieranalyse kopieren" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Wählen Sie die Berechnung" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Aufforderung zur Analyse des Kopierrechners" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Aufforderung zum Kopieren des Website-Projekts" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Aufforderung zur Wiederherstellung des Spiels kopieren" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Nach oben" @@ -11844,26 +11844,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Gleicher Abstand: 256 ÷ 16 = 16 Winkeleinheiten = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Fallstudien" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11881,8 +11861,8 @@ msgstr "Fallstudien" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Fallstudien" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11901,6 +11881,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Erste Schritte" @@ -11933,7 +11933,7 @@ msgstr "Anleitungen" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11953,7 +11953,7 @@ msgstr "Quellcode" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11973,7 +11973,7 @@ msgstr "MIT-Lizenz" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11993,7 +11993,7 @@ msgstr "Zur Community" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -12106,37 +12106,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "So funktioniert REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Einrichtungsanleitung" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Präsentieren →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Ausgewählte Einstellungen aus dem überprüften Skript" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Lesbare Zusammenfassung des % -Handlers" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * ausgewählte Originalanleitung" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Betreiber-IDs * Quelle von Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Folge @reatools auf X" @@ -12551,7 +12546,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12570,7 +12565,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12800,7 +12795,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12813,7 +12808,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12826,7 +12821,7 @@ msgstr "Zum Beispiel: {ph0}Warum ergibt {ph1} im Rechner 220?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12837,7 +12832,7 @@ msgstr "" "{ph3} 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12846,7 +12841,7 @@ msgstr "REA * geladen {ph0} * ausgewählte Linien" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12864,7 +12859,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12924,6 +12919,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "Haupt{ph0} * Zwischenablage-Empfänger" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Erste Schritte {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Einrichtungsanleitung" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/es/messages.po b/website/locales/es/messages.po index 63804cef70..5ad61690c5 100644 --- a/website/locales/es/messages.po +++ b/website/locales/es/messages.po @@ -74,7 +74,7 @@ msgstr "Por N0zoM1z0 * 10 octubre 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6058,7 +6058,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "En esta página" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Configurar REA" @@ -6158,7 +6158,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6166,13 +6166,13 @@ msgstr "¿Ya conoces RE? {ph0}Saltar a guías de análisis →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "¿Quieres profundizar? {ph0}Leer el Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Copia este prompt en tu agente de programación:" @@ -6185,38 +6185,38 @@ msgstr "Copia este prompt en tu agente de programación:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Su agente de codificación" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Copiar" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "O ejecuta esto en la terminal:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Revisa y aprueba los cambios propuestos. Después, reinicia tu agente." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "¿Qué es la ingeniería inversa?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6230,7 +6230,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6242,7 +6242,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6250,7 +6250,7 @@ msgstr "Antes de REA · Lo investigas {ph0}por tu cuenta{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6258,7 +6258,7 @@ msgstr "{ph0}01{ph1} Decodificar ramas" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6266,7 +6266,7 @@ msgstr "{ph0}02{ph1} Rastrear llamadas" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6276,20 +6276,20 @@ msgstr "{ph0}03{ph1} Recuperar la regla" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Original x64 * instrucciones seleccionadas{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * ¿Qué sucursal?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6299,29 +6299,29 @@ msgstr "" "“{ph0}¿0x5b? ¿0x5c?{ph1} Dos saltos al mismo lugar. ¿Qué operadores son " "estos?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Busque los ID de comando y luego etiquete la rama." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Divide ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Multiplicar ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · ¿Qué hacen las funciones llamadas?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6331,25 +6331,25 @@ msgstr "" "\"100 está claro. Las llamadas son solo direcciones. {ph0}¿Cuál divide? ¿Qué" " valor entra?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "" "Inspeccione las funciones auxiliares y rastree los valores almacenados." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Divide" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * ¿Cuál es la regla?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6359,20 +6359,20 @@ msgstr "" "\"Esta ruta se multiplica por la otra entrada. Entonces {ph0}10% significa " "10% de 200{ph1} después de +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "" "Asigne un nombre a los operandos y verifique ambas rutas de operadores." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Razonamiento experto ilustrativo" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6384,20 +6384,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Con REA · Le preguntas a {ph0}tu agente{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Un solo prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6411,21 +6411,21 @@ msgstr "Pregúntale a tu agente {ph0}con palabras normales.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ejemplo de indicador * Calculadora" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Respuesta de ejemplo * basada en los hallazgos" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6433,29 +6433,29 @@ msgid "" msgstr "" "Después de +, el botón % toma {ph0}un porcentaje del primer número.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA proporciona" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Instrucciones del manejador, código descompilado y llamadas." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Tu agente explica" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Qué rama se aplica, qué calcula y cómo reconstruirla." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6468,7 +6468,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6478,37 +6478,37 @@ msgstr "" "{ph0}Probemos con dos ejemplos.{ph1} Cambia la velocidad de un juego, luego " "regresa a la Calculadora y reconstruye su botón%." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Ejemplo 01 * Juego de dinosaurios de Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "¿Por qué el dinosaurio va cada vez más rápido?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Objetivo" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Reconstruye el juego con {ph0}velocidad ajustable.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "¿Por qué realizar ingeniería inversa?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6518,49 +6518,49 @@ msgstr "" "Para reproducir su aceleración, necesitamos {ph0}la regla en el juego " "terrestre{ph1}: qué tan rápido comienza, cuánto aumenta y cuándo se detiene." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Juega a la reconstrucción" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Jugar" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Saltar ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Reiniciar" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Velocidad" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Utilice la regla de aceleración recuperada" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Regla original: comience en 6, luego aumente hacia 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Mueva el control deslizante para establecer su propia velocidad. Espaciar o " "saltar despeja un cactus." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6569,14 +6569,14 @@ msgstr "" "Habilite JavaScript para reproducir la reconstrucción. El código y la " "explicación anteriores siguen siendo legibles." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA devolvió" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6595,7 +6595,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6609,7 +6609,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6623,7 +6623,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6637,7 +6637,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6647,12 +6647,12 @@ msgstr "" "{ph0}Comience a las 6.{ph1} Añadir {ph2}0.001{ph3} cada actualización sin " "colisión, mientras que la velocidad es inferior {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Abra el laboratorio de velocidad →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Qué hace cada parte" @@ -6663,7 +6663,7 @@ msgstr "Qué hace cada parte" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6679,7 +6679,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6696,7 +6696,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6706,13 +6706,13 @@ msgstr "" "{ph0}03 * Tú{ph1}{ph2}Cambia la velocidad y juega{ph3}{ph4}Prueba la " "aceleración original y luego elige tu propio ritmo.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Vea el script, las comprobaciones y cómo probar el análisis" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6723,7 +6723,7 @@ msgstr "" "depuración local y devolvió el archivo cargado {ph0}, incluyendo su fuente y" " resumen." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6737,7 +6737,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6754,7 +6754,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6772,7 +6772,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6781,19 +6781,19 @@ msgstr "" "{ph0}Código de velocidad inspeccionado{ph1} · {ph2} Chromium Autores * " "Licencia BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Ejemplo 02 * Calculadora Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Recrea el botón % de la calculadora." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6804,7 +6804,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6815,29 +6815,29 @@ msgstr "" "Inspeccionamos la aplicación instalada para encontrar a qué número se aplica" " el porcentaje." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Prueba el cálculo" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Restablecer" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% de 200 = 20. Entonces 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Ilustración interactiva de la regla de porcentaje inspeccionado." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6849,7 +6849,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6863,7 +6863,7 @@ msgstr "Para este ejemplo, {ph0} y {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6879,7 +6879,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6891,7 +6891,7 @@ msgstr "" "Windows. Recupere las reglas después de + y×, luego construya una pequeña " "calculadora que use ambas.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Con +" @@ -6899,13 +6899,13 @@ msgstr "Con +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Toma el 10% del primer número.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Con ×" @@ -6913,13 +6913,13 @@ msgstr "Con ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Convierta el 10% en 0.1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Desde la aplicación instalada hasta un botón % que funciona" @@ -6931,7 +6931,7 @@ msgstr "Desde la aplicación instalada hasta un botón % que funciona" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6948,7 +6948,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6960,12 +6960,12 @@ msgstr "" "reglas{ph3}{ph4}Verifique la sucursal con la fuente de Microsoft, luego " "intente + y × arriba.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Vea el código real y cómo se verificó la respuesta" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6986,7 +6986,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6996,17 +6996,17 @@ msgstr "" "{ph0}Porcentaje de implementación{ph1} · {ph2}Casos de prueba de " "Microsoft{ph3} · {ph4}Guía de configuración nativa{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Su primera investigación" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Pruebe REA en una aplicación pequeña." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -7015,12 +7015,12 @@ msgstr "" "Descargue nuestro ejemplo de Notes, rastree su exportación CSV y verifique " "una entrada modificada." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Pruebe el ejemplo guiado" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "¿Quieres profundizar?" @@ -7029,7 +7029,7 @@ msgstr "¿Quieres profundizar?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7049,14 +7049,14 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ejemplo de solicitud * Sitio web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Sí, esta." @@ -7069,7 +7069,7 @@ msgstr "Sí, esta." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7084,7 +7084,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7098,23 +7098,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Vea el estudio de caso de DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Para los pasos de configuración y análisis, elija su objetivo:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Programas de código máquina" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7123,17 +7123,17 @@ msgstr "" "Inspeccione funciones, cadenas, referencias y relaciones función-llamada en " "ejecutables y bibliotecas." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Guía de análisis de programas de código máquina" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript y Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7142,17 +7142,17 @@ msgstr "" "Asigne módulos, rutas, IPC y dependencias nativas desde una carpeta de " "aplicaciones o un archivo ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Flujos de trabajo de aplicaciones" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Actividad del navegador y del tiempo de ejecución" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7161,12 +7161,12 @@ msgstr "" "Capture la actividad seleccionada del navegador o del proceso y, a " "continuación, compare los resultados entre ejecuciones." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Guía de observación del navegador" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "¿Tienes preguntas?" @@ -7177,7 +7177,7 @@ msgstr "¿Tienes preguntas?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7188,12 +7188,12 @@ msgstr "" "{ph3}, o {ph4}abrir un problema de GitHub{ph5} para errores y solicitudes de" " funciones." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Únete a la comunidad" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7217,27 +7217,27 @@ msgstr "" "comportamiento del navegador con su agente de codificación. Siga guías " "claras y pruebe ejemplos interactivos con REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Copie el mensaje de instalación de REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Comando Copiar configuración" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Siga el análisis manual de la calculadora" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Copiar pregunta de porcentaje de la calculadora" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7246,32 +7246,32 @@ msgstr "" "Minijuego de dinosaurios. Usa el botón de salto o presiona la barra " "espaciadora mientras el juego está enfocado." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Copie el mensaje de análisis de dinosaurios" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Elija el cálculo" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Copie el mensaje de análisis de la calculadora" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Copie el mensaje del proyecto del sitio web" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Copiar mensaje de reconstrucción del juego" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Volver arriba" @@ -11763,26 +11763,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Mismo espaciado: 256 ÷ 16 = 16 unidades de ángulo = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Casos prácticos" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11800,8 +11780,8 @@ msgstr "Casos prácticos" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Casos prácticos" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11820,6 +11800,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Primeros pasos" @@ -11852,7 +11852,7 @@ msgstr "Guías" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11872,7 +11872,7 @@ msgstr "Código fuente" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11892,7 +11892,7 @@ msgstr "Licencia MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11912,7 +11912,7 @@ msgstr "Únete a la comunidad" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -12023,37 +12023,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Mira cómo funciona REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Guía de instalación" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Vitrinas →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Configuración seleccionada del script inspeccionado" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Resumen legible del controlador %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * instrucciones originales seleccionadas" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "ID de operador * Fuente de Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Sigue a @ reatools en X" @@ -12465,7 +12460,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12484,7 +12479,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12714,7 +12709,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12727,7 +12722,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12741,7 +12736,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12752,7 +12747,7 @@ msgstr "" "{ph3} da 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12761,7 +12756,7 @@ msgstr "REA * cargado {ph0} * líneas seleccionadas" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12779,7 +12774,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12838,6 +12833,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "principal/{ph0} * receptor del portapapeles" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Primeros pasos {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Guía de instalación" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/fa/messages.po b/website/locales/fa/messages.po index d5fc7e0960..d678fee59a 100644 --- a/website/locales/fa/messages.po +++ b/website/locales/fa/messages.po @@ -73,7 +73,7 @@ msgstr "توسط N0zoM1z0 * 10 اکتبر 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5897,7 +5897,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "در این صفحه" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "راه اندازی REA" @@ -5997,7 +5997,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6006,13 +6006,13 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "میخوای عمیق تر بری ؟ {ph0}وبلاگ را بخوانید →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "این درخواست را در دستیار برنامه‌نویسی خود وارد کنید:" @@ -6025,39 +6025,39 @@ msgstr "این درخواست را در دستیار برنامه‌نویسی #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}عامل برنامه نویسی شما" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "کپی" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "یا این دستور را در ترمینال اجرا کنید:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "" "برنامهٔ تغییرات را بررسی و تأیید کنید، سپس دستیار را دوباره راه‌اندازی کنید." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "مهندسی معکوس چیست؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6070,7 +6070,7 @@ msgstr "فهمیدن شیوهٔ کار نرم‌افزار از راه {ph0}بر #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6082,7 +6082,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6090,7 +6090,7 @@ msgstr "پیش از REA · {ph0}خودتان{ph1} بررسی می‌کنید" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6098,7 +6098,7 @@ msgstr "{ph0}01{ph1} شاخه ها را رمزگشایی کنید" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6106,7 +6106,7 @@ msgstr "{ph0}02{ph1} تماس های ردیابی" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6116,20 +6116,20 @@ msgstr "{ph0}03{ph1} قانون را بازیابی کنید" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}اصلی x64 * دستورالعمل های انتخاب شده{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * کدام شعبه؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6138,29 +6138,29 @@ msgid "" msgstr "" "“{ph0}0x5b? 0x5c?{ph1} دو پرش به یک مکان این ها کدام اپراتورها هستند ؟ ”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "به شناسه های فرمان نگاه کنید ، سپس شاخه را برچسب بزنید." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "تقسیم" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "ضرب ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · تابع‌های فراخوانی‌شده چه می‌کنند؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6170,24 +6170,24 @@ msgstr "" "\"100 روشن است. تماس ها فقط آدرس هستن {ph0}کدام یک تقسیم می شود ؟ کدام " "مقدار وارد می شود ؟ {ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "توابع یاور را بررسی کنید و مقادیر ذخیره شده را ردیابی کنید." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "تقسیم" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * قانون چیست؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6197,19 +6197,19 @@ msgstr "" "\"این مسیر با ورودی دیگر ضرب می شود. پس {ph0}10 درصد به معنی 10 درصد از " "200{ph1} بعد از +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "نام operands و بررسی هر دو مسیر اپراتور." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "استدلال متخصص تصویرسازی" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6221,20 +6221,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "با REA · از {ph0}دستیارتان{ph1} می‌پرسید" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "فقط یک درخواست." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6248,42 +6248,42 @@ msgstr "از دستیار {ph0}با زبان ساده{ph1} سؤال کنید." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال سریع * ماشین حساب" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "مثال پاسخ · بر اساس یافته ها" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "بعد از + ، دکمه % طول می کشد {ph0}درصد از عدد اول{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA چه چیزی فراهم می‌کند" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "دستورالعمل های کنترل کننده ، کد و تماس های کامپایل شده." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "دستیار چه چیزی توضیح می‌دهد" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "" @@ -6291,7 +6291,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6303,7 +6303,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6313,37 +6313,37 @@ msgstr "" "{ph0}بیایید دو مثال را امتحان کنیم.{ph1} سرعت بازی را تغییر دهید ، سپس به " "ماشین حساب برگردید و دکمه % آن را بازسازی کنید." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "مثال 01 * بازی دایناسور Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "چرا دایناسور سریع‌تر می‌شود؟" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "هدف" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "بازی را با {ph0}سرعت قابل تنظیم{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "چرا مهندسی معکوس it ؟" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6353,49 +6353,49 @@ msgstr "" "برای بازتولید شتاب آن ، ما نیاز داریم {ph0}قانون در بازی در حال اجرا{ph1}: " "چقدر سریع شروع می شود ، چقدر افزایش می یابد و چه زمانی متوقف می شود." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "نسخهٔ بازسازی‌شده را بازی کنید" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "بازی" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "پرش ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "شروع دوباره" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "سرعت" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "از قانون شتاب بازیابی شده استفاده کنید" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "قانون اصلی: از 6 شروع کنید ، سپس به سمت 13 افزایش دهید." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "نوار لغزنده را حرکت دهید تا سرعت خود را تنظیم کنید. فضا یا پرش یک کاکتوس را " "پاک می کند." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6403,14 +6403,14 @@ msgid "" msgstr "" "JavaScript را قادر به بازی بازسازی کنید. کد و توضیح بالا قابل خواندن است." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA چه نتیجه‌ای برگرداند" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6429,7 +6429,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6441,7 +6441,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال سریع * بازی د #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6455,7 +6455,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6467,7 +6467,7 @@ msgstr "هدف بازرسی شده: {ph0}نسخه مرورگر مشتق شده #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6477,12 +6477,12 @@ msgstr "" "{ph0}از 6 شروع کن{ph1} اضافه کردن {ph2}0.001{ph3} هر به روز رسانی بدون " "برخورد ، در حالی که سرعت زیر است {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "آزمایشگاه سرعت را باز کنید →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "کاری که هر قسمت انجام میده" @@ -6493,7 +6493,7 @@ msgstr "کاری که هر قسمت انجام میده" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6509,7 +6509,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6526,7 +6526,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6536,13 +6536,13 @@ msgstr "" "{ph0}03 * شما{ph1}{ph2}تغییر سرعت و بازی{ph3}{ph4}شتاب اصلی را امتحان کنید ،" " سپس سرعت خود را انتخاب کنید.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "اسکریپت ، چک ها و نحوه امتحان کردن تحلیل را ببینید" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6552,7 +6552,7 @@ msgstr "" "REA نسخه مرورگر HTTP را از طریق یک اتصال اشکال زدایی محلی بازرسی کرد و نسخه " "بارگذاری شده را بازگرداند {ph0}، از جمله منبع و هضم آن." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6565,7 +6565,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6581,7 +6581,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6598,7 +6598,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6606,19 +6606,19 @@ msgid "" msgstr "" "{ph0}کد سرعت بازرسی شده{ph1} · {ph2} Chromium نویسندگان * مجوز bsd{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "مثال 02 * ماشین حساب Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "دکمهٔ % ماشین‌حساب را بازسازی کنید." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6627,7 +6627,7 @@ msgstr "یک ماشین حساب کوچک بسازید که {ph0}هر دو + و #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6637,29 +6637,29 @@ msgstr "" "همان دکمه استفاده می کند {ph0}قوانین مختلف بعد از + و ×.{ph1} ما برنامه نصب " "شده را بررسی می کنیم تا دریابیم درصد به کدام شماره اعمال می شود." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "محاسبه را امتحان کنید" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "بازنشانی" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10 درصد از 200 = 20 سپس 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "تصویر تعاملی از قانون درصد بازرسی شده." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6671,7 +6671,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6685,7 +6685,7 @@ msgstr "برای این مثال, {ph0} و {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6700,7 +6700,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6712,7 +6712,7 @@ msgstr "" "قوانین را پس از + و × بازیابی کنید ، سپس یک ماشین حساب کوچک بسازید که از هر " "دو استفاده کند.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "با +" @@ -6720,13 +6720,13 @@ msgstr "با +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}10 درصد از عدد اول را بگیرید.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "با ×" @@ -6734,13 +6734,13 @@ msgstr "با ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}10% را به 0.1 تبدیل کنید.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "از برنامه نصب شده به یک دکمه کار %" @@ -6752,7 +6752,7 @@ msgstr "از برنامه نصب شده به یک دکمه کار %" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6770,7 +6770,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6782,12 +6782,12 @@ msgstr "" "بازتولید کنید{ph3}{ph4}شاخه را با منبع مایکروسافت بررسی کنید ، سپس + و × " "بالا را امتحان کنید.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "کد واقعی و نحوه بررسی پاسخ را ببینید" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6807,7 +6807,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6817,17 +6817,17 @@ msgstr "" "{ph0}درصد پیاده سازی{ph1} · {ph2}موارد تست مایکروسافت{ph3} · {ph4}راهنمای " "راه اندازی بومی{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "اولین تحقیق شما" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "REA را در یک برنامه کوچک امتحان کنید." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6836,12 +6836,12 @@ msgstr "" "نمونه یادداشت های ما را دانلود کنید ، صادرات CSV آن را ردیابی کنید و یک " "ورودی تغییر یافته را بررسی کنید." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "مثال هدایت شده را امتحان کنید" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "می‌خواهید بیشتر بدانید؟" @@ -6850,7 +6850,7 @@ msgstr "می‌خواهید بیشتر بدانید؟" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6870,13 +6870,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال prompt * وب سایت" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "بله این یکی" @@ -6889,7 +6889,7 @@ msgstr "بله این یکی" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6902,7 +6902,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}مثال سریع * بازساز #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6915,23 +6915,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}مطالعه موردی DX-Ball را ببینید →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "برای مراحل راه اندازی و تجزیه و تحلیل ، هدف خود را انتخاب کنید:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "برنامه های کد ماشین" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -6940,17 +6940,17 @@ msgstr "" "توابع ، رشته ها ، مرجع ها و روابط فراخوان تابع را در فایل های اجرایی و " "کتابخانه ها بازرسی کنید." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "تجزیه و تحلیل برنامه های کد ماشین راهنما" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript و Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -6959,17 +6959,17 @@ msgstr "" "ماژول های نقشه ، مسیرها ، IPC و وابستگی های بومی از یک پوشه برنامه یا آرشیو " "ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "گردش کار برنامه" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "مرورگر و فعالیت زمان اجرا" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -6978,12 +6978,12 @@ msgstr "" "ضبط مرورگر انتخاب شده یا فعالیت فرآیند ، سپس مقایسه نتایج در سراسر اجرا می " "شود." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "راهنمای مشاهده مرورگر" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "سؤالی دارید؟" @@ -6994,7 +6994,7 @@ msgstr "سؤالی دارید؟" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7004,12 +7004,12 @@ msgstr "" "{ph0}سوالات متداول را بخوانید{ph1}, {ph2}چت با ما در Discord {ph3} یا " "{ph4}یک شماره GitHub را باز کنید{ph5} برای اشکالات و درخواست ویژگی." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "به جامعه بپیوندید" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7032,27 +7032,27 @@ msgstr "" "برنامه های مهندسی معکوس ، باینری ها و رفتار مرورگر با عامل برنامه نویسی شما." " راهنماهای واضح را دنبال کنید و نمونه های تعاملی را با REA امتحان کنید ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "کپی REA نصب و راه اندازی prompt" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "دستور Copy setup" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "تجزیه و تحلیل ماشین حساب دستی را دنبال کنید" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "کپی ماشین حساب درصد سوال" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7061,32 +7061,32 @@ msgstr "" "دایناسور مینی بازی. در حالی که بازی متمرکز است از دکمه پرش یا فضای فشار " "استفاده کنید." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "کپی دایناسور تجزیه و تحلیل فوری" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "محاسبه را انتخاب کنید" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "کپی ماشین حساب تجزیه و تحلیل سریع" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "کپی وب سایت project prompt" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "کپی بازی بازسازی سریع" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "بازگشت به بالا" @@ -11478,26 +11478,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "فاصله یکسان: 256 × 16 = 16 واحد زاویه = 22.5 درجه" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "نمونه‌ها" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11515,8 +11495,8 @@ msgstr "نمونه‌ها" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "وبلاگ" +msgid "Showcases" +msgstr "نمونه‌ها" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11535,6 +11515,26 @@ msgstr "وبلاگ" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "وبلاگ" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "شروع کنید" @@ -11567,7 +11567,7 @@ msgstr "راهنماها" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11587,7 +11587,7 @@ msgstr "کد منبع" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11607,7 +11607,7 @@ msgstr "مجوز MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11627,7 +11627,7 @@ msgstr "به جامعه بپیوندید" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11738,37 +11738,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "ببینید REA چطور کار می‌کند ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "راهنمای نصب" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "ویترین ها →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "تنظیمات انتخاب شده از اسکریپت بازرسی شده" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "خلاصه قابل خواندن از % handler" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * دستورالعمل های اصلی انتخاب شده" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "شناسه های اپراتور * منبع مایکروسافت" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "@Reatools را در X دنبال کنید" @@ -12179,7 +12174,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12198,7 +12193,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12425,7 +12420,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12438,7 +12433,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12451,7 +12446,7 @@ msgstr "مثلاً: {ph0}چرا ماشین‌حساب برای {ph1} عدد 220 #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12462,7 +12457,7 @@ msgstr "" "220 می دهند؟{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12471,7 +12466,7 @@ msgstr "REA * بارگذاری شده {ph0} * خطوط انتخاب شده" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12489,7 +12484,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12548,6 +12543,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "اصلی/{ph0} * گیرنده کلیپ بورد" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "شروع کنید {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "راهنمای نصب" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/fr/messages.po b/website/locales/fr/messages.po index ca9847bef8..322e3b9d2e 100644 --- a/website/locales/fr/messages.po +++ b/website/locales/fr/messages.po @@ -74,7 +74,7 @@ msgstr "Par N0zoM1z0 · 10 octobre 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6064,7 +6064,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Sur cette page" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Mettre en place REA" @@ -6164,7 +6164,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6172,13 +6172,13 @@ msgstr "Vous connaissez déjà RE? {ph0}Passer aux guides d'analyse →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Vous voulez aller plus loin? {ph0}Lire le Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Copiez ce prompt dans votre agent de programmation :" @@ -6191,39 +6191,39 @@ msgstr "Copiez ce prompt dans votre agent de programmation :" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Votre agent de codage" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Copier" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Ou exécutez cette commande dans le terminal :" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "" "Vérifiez et approuvez les changements prévus, puis redémarrez votre agent." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Qu’est-ce que la rétro-ingénierie ?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6238,7 +6238,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6250,7 +6250,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6258,7 +6258,7 @@ msgstr "Avant REA · Vous enquêtez {ph0}vous-même{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6266,7 +6266,7 @@ msgstr "{ph0}01{ph1} Décoder les branches" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6274,7 +6274,7 @@ msgstr "{ph0}02{ph1} Tracer les appels" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6284,20 +6284,20 @@ msgstr "{ph0}03{ph1} Récupérer la règle" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Instructions d'origine x64 · sélectionnées{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * Quelle branche?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6307,29 +6307,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Deux sauts au même endroit. De quels opérateurs " "s'agit-il?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Recherchez les identifiants de commande, puis étiquetez la branche." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Diviser ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Multiplier ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Que font les fonctions appelées ?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6339,24 +6339,24 @@ msgstr "" "\"100 est clair. Les appels ne sont que des adresses. {ph0}Lequel divise? " "Quelle valeur entre?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Inspectez les fonctions d'assistance et tracez les valeurs stockées." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Diviser" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * Quelle est la règle?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6366,19 +6366,19 @@ msgstr "" "\"Ce chemin se multiplie par l'autre entrée. Donc {ph0}10% signifie 10% de " "200{ph1} après +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Nommez les opérandes et vérifiez les deux chemins d'opérateur." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Raisonnement d'expert illustratif" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6390,20 +6390,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Avec REA · Vous demandez à {ph0}votre agent{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Un seul prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6417,21 +6417,21 @@ msgstr "Posez votre question à l’agent {ph0}avec des mots simples.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Exemple d'invite * Calculatrice" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Exemple de réponse · basé sur les résultats" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6439,22 +6439,22 @@ msgid "" msgstr "" "Après +, le bouton % prend {ph0}un pourcentage du premier nombre.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA fournit" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Les instructions du gestionnaire, le code décompilé et les appels." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Votre agent explique" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "" @@ -6462,7 +6462,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6475,7 +6475,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6485,37 +6485,37 @@ msgstr "" "{ph0}Essayons deux exemples.{ph1} Modifiez la vitesse d'un jeu, puis revenez" " à la calculatrice et reconstruisez son bouton%." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Exemple 01 * Jeu de dinosaures de Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Pourquoi le dinosaure accélère-t-il ?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Objectif" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Reconstruisez le jeu avec {ph0}vitesse réglable.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Pourquoi faire de l'ingénierie inverse?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6526,49 +6526,49 @@ msgstr "" "course{ph1}: à quelle vitesse il démarre, combien il augmente et quand il " "s'arrête." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Jouer à la reconstruction" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Jouer" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Sauter ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Recommencer" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Vitesse" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Utilisez la règle d'accélération récupérée" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Règle d'origine: commencez à 6, puis augmentez vers 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Déplacez le curseur pour définir votre propre vitesse. L'espace ou le saut " "efface un cactus." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6577,14 +6577,14 @@ msgstr "" "Activez JavaScript pour lire la reconstruction. Le code et l'explication ci-" "dessus restent lisibles." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA a renvoyé" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6603,7 +6603,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6616,7 +6616,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6630,7 +6630,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6644,7 +6644,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6654,12 +6654,12 @@ msgstr "" "{ph0}Commencez à 6 heures.{ph1} Ajouter {ph2}0.001{ph3} chaque mise à jour " "sans collision, alors que la vitesse est inférieure {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Ouvrir le laboratoire de vitesse →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Ce que fait chaque partie" @@ -6670,7 +6670,7 @@ msgstr "Ce que fait chaque partie" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6687,7 +6687,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6704,7 +6704,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6714,13 +6714,13 @@ msgstr "" "{ph0}03 * Vous{ph1}{ph2}Changez la vitesse et jouez{ph3}{ph4}Essayez " "l'accélération d'origine, puis choisissez votre propre rythme.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Voir le script, les vérifications et comment essayer l'analyse" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6731,7 +6731,7 @@ msgstr "" "locale et a renvoyé le fichier chargé {ph0}, y compris sa source et son " "résumé." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6745,7 +6745,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6762,7 +6762,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6780,7 +6780,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6789,19 +6789,19 @@ msgstr "" "{ph0}Code de vitesse inspecté{ph1} · {ph2} Auteurs de Chromium * Licence " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Exemple 02 · Windows Calculatrice" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Recréez le bouton % de la calculatrice." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6812,7 +6812,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6823,29 +6823,29 @@ msgstr "" "inspectons l'application installée pour trouver à quel numéro le pourcentage" " est appliqué." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Essayez le calcul" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Réinitialiser" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% de 200 = 20. Alors 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Illustration interactive de la règle du pourcentage inspecté." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6857,7 +6857,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6871,7 +6871,7 @@ msgstr "Pour cet exemple, {ph0} et {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6887,7 +6887,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6899,7 +6899,7 @@ msgstr "" "Windows. Récupérez les règles après + et×, puis créez une petite " "calculatrice qui utilise les deux.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Avec +" @@ -6907,13 +6907,13 @@ msgstr "Avec +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Prenez 10% du premier nombre.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Avec ×" @@ -6921,13 +6921,13 @@ msgstr "Avec ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Transformez 10% en 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "De l'application installée à un bouton % fonctionnel" @@ -6939,7 +6939,7 @@ msgstr "De l'application installée à un bouton % fonctionnel" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6957,7 +6957,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6969,12 +6969,12 @@ msgstr "" "règles{ph3}{ph4}Vérifiez la branche avec la source de Microsoft, puis " "essayez + et × ci-dessus.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Voir le vrai code et comment la réponse a été vérifiée" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6995,7 +6995,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7005,17 +7005,17 @@ msgstr "" "{ph0}Pourcentage de mise en œuvre{ph1} · {ph2}Scénarios de test de " "Microsoft{ph3} · {ph4}Guide de configuration natif{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Votre première enquête" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Essayez REA sur une petite application." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -7024,12 +7024,12 @@ msgstr "" "Téléchargez notre exemple de Notes, tracez son exportation CSV et vérifiez " "une entrée modifiée." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Essayez l'exemple guidé" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Vous voulez aller plus loin ?" @@ -7038,7 +7038,7 @@ msgstr "Vous voulez aller plus loin ?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7059,13 +7059,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Exemple d'invite * Site Web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Oui, celui-ci." @@ -7078,7 +7078,7 @@ msgstr "Oui, celui-ci." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7093,7 +7093,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7107,24 +7107,24 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Voir l'étude de cas DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "" "Pour les étapes de configuration et d'analyse, choisissez votre cible:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Programmes en code machine" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7133,17 +7133,17 @@ msgstr "" "Inspectez les fonctions, les chaînes, les références et les relations " "d'appel de fonction dans les exécutables et les bibliothèques." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Guide d'analyse des programmes en code machine" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7152,17 +7152,17 @@ msgstr "" "Mappez les modules, les routes, l'IPC et les dépendances natives à partir " "d'un dossier d'application ou d'une archive ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Flux de travail des applications" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Activité du navigateur et de l'exécution" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7171,12 +7171,12 @@ msgstr "" "Capturez l'activité du navigateur ou du processus sélectionné, puis comparez" " les résultats entre les exécutions." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Guide d'observation du navigateur" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Des questions ?" @@ -7187,7 +7187,7 @@ msgstr "Des questions ?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7198,12 +7198,12 @@ msgstr "" "{ph4}ouvrir un problème GitHub{ph5} pour les bugs et les demandes de " "fonctionnalités." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Rejoindre la communauté" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7227,27 +7227,27 @@ msgstr "" "navigateur avec votre agent de codage. Suivez des guides clairs et essayez " "des exemples interactifs avec REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Copier l'invite d'installation de REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Commande Copier le programme d'installation" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Suivez l'analyse manuelle de la calculatrice" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Question sur le pourcentage de la calculatrice de copie" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7256,32 +7256,32 @@ msgstr "" "Mini-jeu de dinosaure. Utilisez le bouton de saut ou appuyez sur Espace " "pendant que le jeu est concentré." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Copie de l'invite d'analyse des dinosaures" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Choisissez le calcul" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Invite d'analyse de la calculatrice de copie" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Copier l'invite de projet de site Web" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Copie de l'invite de reconstruction du jeu" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Haut de page" @@ -11774,26 +11774,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Même espacement: 256 ÷ 16 = 16 unités d'angle = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Études de cas" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11811,8 +11791,8 @@ msgstr "Études de cas" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Études de cas" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11831,6 +11811,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Démarrer" @@ -11863,7 +11863,7 @@ msgstr "Guides" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11883,7 +11883,7 @@ msgstr "Code source" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11903,7 +11903,7 @@ msgstr "Licence MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11923,7 +11923,7 @@ msgstr "Rejoindre la communauté" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -12034,37 +12034,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Voir comment fonctionne REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Guide d’installation" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Vitrines →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Paramètres sélectionnés à partir du script inspecté" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Résumé lisible du gestionnaire %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * instructions d'origine sélectionnées" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Identifiants de l'opérateur * Source de Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Suivez @ reatools sur X" @@ -12477,7 +12472,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12496,7 +12491,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12727,7 +12722,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12740,7 +12735,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12755,7 +12750,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12766,7 +12761,7 @@ msgstr "" "Pourquoi {ph3} donne-t-il 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12775,7 +12770,7 @@ msgstr "REA * chargé {ph0} * lignes sélectionnées" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12793,7 +12788,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12852,6 +12847,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "principale/{ph0} * récepteur de presse-papiers" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Démarrer {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Guide d’installation" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell pour ordinateur portable" diff --git a/website/locales/id/messages.po b/website/locales/id/messages.po index 6ac7568f65..c28fa2d1e3 100644 --- a/website/locales/id/messages.po +++ b/website/locales/id/messages.po @@ -74,7 +74,7 @@ msgstr "Oleh N0zoM1z0 · 10 Oktober 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6001,7 +6001,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Di halaman ini" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Siapkan REA" @@ -6101,7 +6101,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6109,13 +6109,13 @@ msgstr "Sudah tahu RE? {ph0}Lewati ke panduan analisis →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Ingin lebih dalam? {ph0}Baca Blognya →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Tempel prompt ini ke agen pemrograman Anda:" @@ -6128,38 +6128,38 @@ msgstr "Tempel prompt ini ke agen pemrograman Anda:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Agen pengkodean Anda" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Salin" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Atau jalankan perintah ini di terminal:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Periksa dan setujui rencana perubahan, lalu mulai ulang agen Anda." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Apa itu rekayasa balik?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6174,7 +6174,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6186,7 +6186,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6194,7 +6194,7 @@ msgstr "Sebelum REA · Anda menyelidikinya {ph0}sendiri{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6202,7 +6202,7 @@ msgstr "{ph0}01{ph1} Dekode cabang" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6210,7 +6210,7 @@ msgstr "{ph0}02{ph1} Melacak panggilan" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6220,20 +6220,20 @@ msgstr "{ph0}03{ph1} Pulihkan aturan" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}x64 asli * instruksi yang dipilih{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * Cabang mana?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6242,29 +6242,29 @@ msgid "" msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Dua lompatan ke tempat yang sama. Operator mana ini?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Cari ID perintah, lalu beri label pada cabang." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Membagi ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Kalikan ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Apa yang dilakukan fungsi yang dipanggil?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6274,24 +6274,24 @@ msgstr "" "\"100 sudah jelas. Panggilan itu hanya alamat. {ph0}Yang mana yang membelah?" " Nilai mana yang masuk?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Periksa fungsi pembantu dan lacak nilai yang disimpan." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Membagi" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 · Apa aturannya?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6301,19 +6301,19 @@ msgstr "" "\"Jalur ini dikalikan dengan masukan lainnya. Jadi {ph0}10% berarti 10% dari" " 200{ph1} setelah +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Beri nama operan dan periksa kedua jalur operator." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Penalaran ahli ilustratif" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6325,20 +6325,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Dengan REA · Anda bertanya kepada {ph0}agen Anda{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Cukup satu prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6352,20 +6352,20 @@ msgstr "Tanyakan kepada agen Anda {ph0}dengan bahasa sehari-hari.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Contoh prompt * Kalkulator" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Contoh jawaban * berdasarkan temuan" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6373,22 +6373,22 @@ msgid "" msgstr "" "Setelah +, tombol % mengambil {ph0}persentase dari angka pertama.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA menyediakan" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Instruksi pawang, kode yang didekompilasi, dan panggilan." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Agen Anda menjelaskan" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "" @@ -6397,7 +6397,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6410,7 +6410,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6420,38 +6420,38 @@ msgstr "" "{ph0}Mari kita coba dua contoh.{ph1} Ubah kecepatan permainan, lalu kembali " "ke Kalkulator dan buat kembali tombol % - nya." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Contoh 01 * Game dinosaurus Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Mengapa dinosaurus semakin cepat?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Tujuan" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "" "Bangun kembali game dengan {ph0}kecepatan yang dapat disesuaikan.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Mengapa merekayasa baliknya?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6462,49 +6462,49 @@ msgstr "" "permainan lari{ph1}: seberapa cepat dimulai, seberapa banyak meningkat dan " "kapan berhenti." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Mainkan hasil rekonstruksi" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Main" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Lompat ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Mulai ulang" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Kecepatan" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Gunakan aturan akselerasi yang dipulihkan" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Aturan awal: mulai dari 6, lalu tingkatkan menjadi 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Gerakkan penggeser untuk mengatur kecepatan Anda sendiri. Ruang atau " "Lompatan membersihkan kaktus." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6513,14 +6513,14 @@ msgstr "" "Aktifkan JavaScript untuk memainkan rekonstruksi. Kode dan penjelasan di " "atas tetap dapat dibaca." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA mengembalikan" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6539,7 +6539,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6552,7 +6552,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6566,7 +6566,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6579,7 +6579,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6589,12 +6589,12 @@ msgstr "" "{ph0}Mulai dari 6.{ph1} Tambahkan {ph2}0.001{ph3} setiap pembaruan tanpa " "tabrakan, sementara kecepatan di bawah {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Buka lab kecepatan →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Apa yang dilakukan setiap bagian" @@ -6605,7 +6605,7 @@ msgstr "Apa yang dilakukan setiap bagian" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6622,7 +6622,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6639,7 +6639,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6649,13 +6649,13 @@ msgstr "" "{ph0}03 * Kamu{ph1}{ph2}Mengubah kecepatan dan bermain{ph3}{ph4}Coba " "akselerasi asli, lalu pilih kecepatan Anda sendiri.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Lihat skrip, cek, dan cara mencoba analisisnya" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6665,7 +6665,7 @@ msgstr "" "REA memeriksa edisi browser HTTP melalui koneksi debugging lokal dan " "mengembalikan yang dimuat {ph0}, termasuk sumber dan intisarinya." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6679,7 +6679,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6696,7 +6696,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6714,7 +6714,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6723,19 +6723,19 @@ msgstr "" "{ph0}Kode kecepatan yang diperiksa{ph1} · {ph2} Penulis Chromium * Lisensi " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Contoh 02 * Kalkulator Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Buat ulang tombol % Kalkulator." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6744,7 +6744,7 @@ msgstr "Buat kalkulator kecil yang menangani {ph0}keduanya + dan × {ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6755,29 +6755,29 @@ msgstr "" "memeriksa aplikasi yang diinstal untuk menemukan angka persentase yang " "diterapkan." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Coba perhitungannya" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Reset" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% dari 200 = 20. Kemudian 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Ilustrasi interaktif dari aturan persentase yang diinspeksi." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6789,7 +6789,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6803,7 +6803,7 @@ msgstr "Untuk contoh ini, {ph0} dan {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6819,7 +6819,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6830,7 +6830,7 @@ msgstr "" "{ph0}›{ph1}{ph2}Gunakan REA untuk memeriksa tombol % Kalkulator Windows. " "Pulihkan aturan setelah + dan × {ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Dengan +" @@ -6838,13 +6838,13 @@ msgstr "Dengan +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Ambil 10% dari angka pertama.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Dengan ×" @@ -6852,13 +6852,13 @@ msgstr "Dengan ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Ubah 10% menjadi 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Dari aplikasi yang diinstal ke tombol % yang berfungsi" @@ -6870,7 +6870,7 @@ msgstr "Dari aplikasi yang diinstal ke tombol % yang berfungsi" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6888,7 +6888,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6900,12 +6900,12 @@ msgstr "" "aturan{ph3}{ph4}Periksa silang cabang dengan sumber Microsoft, lalu coba + " "dan × {ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Lihat kode sebenarnya dan bagaimana jawabannya diperiksa" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6926,7 +6926,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6936,17 +6936,17 @@ msgstr "" "{ph0}Implementasi persentase{ph1} · {ph2}Kasus uji Microsoft{ph3} · " "{ph4}Panduan penyiapan asli{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Investigasi pertamamu" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Coba REA di aplikasi kecil." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6955,12 +6955,12 @@ msgstr "" "Unduh contoh Catatan kami, lacak ekspor CSV - nya, dan periksa satu input " "yang diubah." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Coba contoh terpandu" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Ingin mempelajari lebih lanjut?" @@ -6969,7 +6969,7 @@ msgstr "Ingin mempelajari lebih lanjut?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6989,13 +6989,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Contoh prompt * Situs web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Ya, yang ini." @@ -7008,7 +7008,7 @@ msgstr "Ya, yang ini." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7022,7 +7022,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7036,23 +7036,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Lihat studi kasus DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Untuk langkah penyiapan dan analisis, pilih target Anda:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Program kode mesin" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7061,17 +7061,17 @@ msgstr "" "Periksa fungsi, string, referensi, dan hubungan pemanggilan fungsi di " "executable dan pustaka." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Analisis panduan program kode mesin" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7080,17 +7080,17 @@ msgstr "" "Modul peta, rute, IPC, dan dependensi asli dari folder aplikasi atau arsip " "ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Alur kerja aplikasi" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Aktivitas browser & waktu proses" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7099,12 +7099,12 @@ msgstr "" "Tangkap browser atau aktivitas proses yang dipilih, lalu bandingkan hasilnya" " di seluruh proses." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Panduan pengamatan browser" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Ada pertanyaan?" @@ -7115,7 +7115,7 @@ msgstr "Ada pertanyaan?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7125,12 +7125,12 @@ msgstr "" "{ph0}Baca FAQ{ph1}, {ph2}ngobrol dengan kami di Discord {ph3}, atau " "{ph4}buka masalah GitHub{ph5} untuk bug dan permintaan fitur." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Gabung komunitas" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7154,27 +7154,27 @@ msgstr "" "pengkodean Anda. Ikuti panduan yang jelas dan coba contoh interaktif dengan " "REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Salin prompt instalasi REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Salin perintah pengaturan" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Ikuti analisis Kalkulator manual" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Salin pertanyaan persentase Kalkulator" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7183,32 +7183,32 @@ msgstr "" "Mini-game dinosaurus. Gunakan tombol Lompat atau tekan Spasi saat permainan " "difokuskan." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Salin prompt analisis dinosaurus" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Pilih perhitungan" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Salin prompt analisis Kalkulator" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Salin prompt proyek situs web" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Salin prompt rekonstruksi game" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Kembali ke atas" @@ -11653,26 +11653,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Jarak yang sama: 256 ÷ 16 = 16 satuan sudut = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Studi kasus" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11690,8 +11670,8 @@ msgstr "Studi kasus" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Studi kasus" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11710,6 +11690,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Mulai" @@ -11742,7 +11742,7 @@ msgstr "Panduan" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11762,7 +11762,7 @@ msgstr "Kode sumber" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11782,7 +11782,7 @@ msgstr "Lisensi MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11802,7 +11802,7 @@ msgstr "Gabung komunitas" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11913,37 +11913,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Lihat cara kerja REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Panduan pemasangan" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Menampilkan →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Pengaturan yang dipilih dari skrip yang diinspeksi" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Ringkasan yang dapat dibaca dari % handler" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * instruksi asli yang dipilih" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "ID Operator * Sumber Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Ikuti @ reatools di X" @@ -12355,7 +12350,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12374,7 +12369,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12602,7 +12597,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12615,7 +12610,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12628,7 +12623,7 @@ msgstr "Contohnya: {ph0}mengapa Kalkulator menghasilkan 220 untuk {ph1}?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12639,7 +12634,7 @@ msgstr "" "{ph3} menghasilkan 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12648,7 +12643,7 @@ msgstr "REA * dimuat {ph0} * baris yang dipilih" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12666,7 +12661,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12725,6 +12720,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "utama/{ph0} * penerima papan klip" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Mulai {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Panduan pemasangan" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/it/messages.po b/website/locales/it/messages.po index 6ad52be8f4..d47a673553 100644 --- a/website/locales/it/messages.po +++ b/website/locales/it/messages.po @@ -73,7 +73,7 @@ msgstr "Di N0zoM1z0 * 10 ottobre 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6033,7 +6033,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "In questa pagina" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Impostare REA" @@ -6133,7 +6133,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6141,13 +6141,13 @@ msgstr "Sai già RE? {ph0}Vai alle guide di analisi →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Vuoi andare più a fondo? {ph0}Leggi il Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Copia questo prompt nel tuo agente di programmazione:" @@ -6160,38 +6160,38 @@ msgstr "Copia questo prompt nel tuo agente di programmazione:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Il tuo agente di codifica" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Copia" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Oppure esegui questo comando nel terminale:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Controlla e approva le modifiche previste, poi riavvia il tuo agente." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Che cos’è il reverse engineering?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6205,7 +6205,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6217,7 +6217,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6225,7 +6225,7 @@ msgstr "Prima di REA · Indaghi {ph0}da solo{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6233,7 +6233,7 @@ msgstr "{ph0}01{ph1} Decodifica rami" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6241,7 +6241,7 @@ msgstr "{ph0}02{ph1} Traccia chiamate" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6251,20 +6251,20 @@ msgstr "{ph0}03{ph1} Recuperare la regola" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Originale x64 * istruzioni selezionate{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * Quale ramo ·" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6274,29 +6274,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Due salti nello stesso posto. Quali operatori sono " "questi?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Cerca gli ID del comando, quindi etichetta il ramo." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Dividere ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Moltiplicare ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Che cosa fanno le funzioni chiamate?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6306,24 +6306,24 @@ msgstr "" "\"100 è chiaro. Le chiamate sono solo indirizzi. {ph0}Quale divide? Quale " "valore entra?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Ispezionare le funzioni di supporto e tracciare i valori memorizzati." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Dividere" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * Qual è la regola ·" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6333,20 +6333,20 @@ msgstr "" "\"Questo percorso si moltiplica per l'altro input. Cosi {ph0}10% significa " "10% di 200{ph1} dopo +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "" "Assegnare un nome agli operandi e controllare entrambi i percorsi operatore." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Ragionamento illustrativo esperto" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6358,20 +6358,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Con REA · Chiedi al {ph0}tuo agente{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Un solo prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6385,49 +6385,49 @@ msgstr "Fai una domanda al tuo agente {ph0}con parole semplici.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Esempio prompt * Calcolatrice" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Esempio di risposta * in base ai risultati" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "Dopo +, il pulsante % prende {ph0}percentuale del primo numero.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA fornisce" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Istruzioni del gestore, codice decompilato e chiamate." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Il tuo agente spiega" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Quale ramo si applica, cosa calcola e come ricostruirlo." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6440,7 +6440,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6450,37 +6450,37 @@ msgstr "" "{ph0}Proviamo due esempi.{ph1} Modificare la velocità di un gioco, quindi " "tornare a Calcolatrice e ricostruire il suo pulsante%." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Esempio 01 * Gioco di dinosauri di Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Perché il dinosauro diventa più veloce?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Obiettivo" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Ricostruisci il gioco con {ph0}velocità regolabile.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Perché il reverse engineering?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6491,49 +6491,49 @@ msgstr "" "gioco in esecuzione{ph1}: quanto velocemente inizia, quanto aumenta e quando" " si ferma." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Gioca alla ricostruzione" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Gioca" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Salta ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Ricomincia" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Velocità" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Utilizzare la regola di accelerazione recuperata" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Regola originale: inizia da 6, quindi aumenta verso 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Spostare il cursore per impostare la propria velocità. Spazio o salto " "cancella un cactus." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6542,14 +6542,14 @@ msgstr "" "Abilita JavaScript per riprodurre la ricostruzione. Il codice e la " "spiegazione di cui sopra rimangono leggibili." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA ha restituito" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6568,7 +6568,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6581,7 +6581,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6595,7 +6595,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6609,7 +6609,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6619,12 +6619,12 @@ msgstr "" "{ph0}Inizia alle 6.{ph1} Aggiungere {ph2}0.001{ph3} ogni aggiornamento senza" " una collisione, mentre la velocità è inferiore {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Apri lo speed lab" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Cosa fa ogni parte" @@ -6635,7 +6635,7 @@ msgstr "Cosa fa ogni parte" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6652,7 +6652,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6669,7 +6669,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6679,13 +6679,13 @@ msgstr "" "{ph0}03 * Tu{ph1}{ph2}Cambiare la velocità e giocare{ph3}{ph4}Prova " "l'accelerazione originale, quindi scegli il tuo ritmo.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Vedi lo script, i controlli e come provare l'analisi" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6695,7 +6695,7 @@ msgstr "" "REA ispezionato l'edizione del browser HTTP tramite una connessione di debug" " locale e restituito il caricato {ph0}, compresa la sua fonte e digest." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6709,7 +6709,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6726,7 +6726,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6744,7 +6744,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6753,19 +6753,19 @@ msgstr "" "{ph0}Codice di velocità ispezionato{ph1} · {ph2} Autori Chromium * Licenza " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Esempio 02 * Calcolatrice Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Ricrea il pulsante % della calcolatrice." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6776,7 +6776,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6787,29 +6787,29 @@ msgstr "" "Controlliamo l'app installata per trovare a quale numero viene applicata la " "percentuale." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Prova il calcolo" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Reimposta" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% di 200 = 20. Quindi 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Illustrazione interattiva della regola percentuale ispezionata." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6821,7 +6821,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6835,7 +6835,7 @@ msgstr "Per questo esempio, {ph0} e {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6851,7 +6851,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6863,7 +6863,7 @@ msgstr "" "calcolatrice Windows. Recuperare le regole dopo + e×, quindi costruire una " "piccola calcolatrice che utilizza entrambi.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Con +" @@ -6871,13 +6871,13 @@ msgstr "Con +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Prendi il 10% del primo numero.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Con ×" @@ -6885,13 +6885,13 @@ msgstr "Con ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Trasforma il 10% in 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Dall'app installata a un pulsante % funzionante" @@ -6903,7 +6903,7 @@ msgstr "Dall'app installata a un pulsante % funzionante" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6921,7 +6921,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6933,12 +6933,12 @@ msgstr "" "regole{ph3}{ph4}Crosscheck il ramo con la fonte di Microsoft, quindi provare" " + e × sopra.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Vedi il codice reale e come è stata controllata la risposta" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6959,7 +6959,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6969,17 +6969,17 @@ msgstr "" "{ph0}Percentuale di attuazione{ph1} · {ph2}I test case di Microsoft{ph3} · " "{ph4}Guida all'installazione nativa{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "La tua prima indagine" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Prova REA su una piccola app." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6988,12 +6988,12 @@ msgstr "" "Scarica il nostro esempio di Note, traccia la sua esportazione CSV e " "controlla un input modificato." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Prova l'esempio guidato" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Vuoi approfondire?" @@ -7002,7 +7002,7 @@ msgstr "Vuoi approfondire?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7022,13 +7022,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Esempio prompt * Sito web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Sì, questa." @@ -7041,7 +7041,7 @@ msgstr "Sì, questa." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7056,7 +7056,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7070,23 +7070,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Vedere il caso di studio DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Per le fasi di configurazione e analisi, scegli il tuo target:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Programmi a codice macchina" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7095,17 +7095,17 @@ msgstr "" "Ispezionare funzioni, stringhe, riferimenti e relazioni di chiamata di " "funzione in eseguibili e librerie." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Guida all'analisi dei programmi di codice macchina" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7114,17 +7114,17 @@ msgstr "" "Mappa moduli, percorsi, IPC e dipendenze native da una cartella " "dell'applicazione o da un archivio ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Flussi di lavoro delle applicazioni" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Attività del browser e runtime" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7133,12 +7133,12 @@ msgstr "" "Cattura l'attività del browser o del processo selezionata, quindi confronta " "i risultati tra le varie esecuzioni." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Guida all'osservazione del browser" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Hai domande?" @@ -7149,7 +7149,7 @@ msgstr "Hai domande?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7159,12 +7159,12 @@ msgstr "" "{ph0}Leggi le FAQ{ph1}, {ph2}chatta con noi su Discord {ph3}, o {ph4}aprire " "un problema GitHub{ph5} per bug e richieste di funzionalità." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Unisciti alla community" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7187,27 +7187,27 @@ msgstr "" "Decodificare app, binari e comportamento del browser con il tuo agente di " "codifica. Segui guide chiare e prova esempi interattivi con REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Copia REA prompt di installazione" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Copia comando setup" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Seguire l'analisi calcolatrice manuale" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Copia Calcolatrice percentuale domanda" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7216,32 +7216,32 @@ msgstr "" "Dinosauro mini-gioco. Utilizzare il pulsante di salto o premere Spazio " "mentre il gioco è focalizzato." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Copia dinosaur analysis prompt" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Scegli il calcolo" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Copia Calcolatrice analisi prompt" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Copia il prompt del progetto del sito web" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Copia gioco ricostruzione prompt" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Torna in alto" @@ -11711,26 +11711,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Stessa spaziatura: 256 ÷ 16 = 16 unità angolari = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Casi pratici" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11748,8 +11728,8 @@ msgstr "Casi pratici" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Casi pratici" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11768,6 +11748,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Inizia" @@ -11800,7 +11800,7 @@ msgstr "Guide" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11820,7 +11820,7 @@ msgstr "Codice sorgente" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11840,7 +11840,7 @@ msgstr "Licenza MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11860,7 +11860,7 @@ msgstr "Unisciti alla community" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11971,37 +11971,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Guarda come funziona REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Guida all’installazione" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Vetrina →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Impostazioni selezionate dallo script ispezionato" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Riepilogo leggibile del gestore %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * istruzioni originali selezionate" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "ID operatore * Fonte di Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Segui @reatools su X" @@ -12412,7 +12407,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12431,7 +12426,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12662,7 +12657,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12675,7 +12670,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12689,7 +12684,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12700,7 +12695,7 @@ msgstr "" " dà 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12709,7 +12704,7 @@ msgstr "REA * caricato {ph0} * linee selezionate" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12727,7 +12722,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12786,6 +12781,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "principale/{ph0} * ricevitore appunti" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Inizia {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Guida all’installazione" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/ja/messages.po b/website/locales/ja/messages.po index 444197aad3..dc0d6e7f6d 100644 --- a/website/locales/ja/messages.po +++ b/website/locales/ja/messages.po @@ -72,7 +72,7 @@ msgstr "によってN0zoM1z0•10 10月2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5572,7 +5572,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "このページでは" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "REAのセットアップ" @@ -5670,7 +5670,7 @@ msgstr "REA は、プログラムを調べて動作を説明するためのツ #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5678,13 +5678,13 @@ msgstr "すでに再を知っていますか? {ph0}分析ガイドへスキッ #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "もっと深く行きたいですか? {ph0}ブログを読む→{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "このプロンプトをコーディングエージェントに貼り付けてください:" @@ -5697,38 +5697,38 @@ msgstr "このプロンプトをコーディングエージェントに貼り付 #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}あなたのコーディングエージェント" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "コピー" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "ターミナルから実行することもできます:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "設定の変更内容を確認して承認したら、エージェントを再起動します。" -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "リバースエンジニアリングとは?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5741,7 +5741,7 @@ msgstr "{ph0}プログラムそのものを調べて{ph1}、ソフトウェア #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5753,7 +5753,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -5761,7 +5761,7 @@ msgstr "REA を使う前 · {ph0}自分で{ph1}調べる" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -5769,7 +5769,7 @@ msgstr "{ph0}01{ph1} 枝をデコードする" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -5777,7 +5777,7 @@ msgstr "{ph0}02{ph1} トレース呼び出し" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -5787,20 +5787,20 @@ msgstr "{ph0}03{ph1} ルールを回復する" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}オリジナルx64·選択された指示{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01*どの支店?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5808,29 +5808,29 @@ msgid "" "these?”" msgstr "“{ph0}0x5b? 0x5c?{ph1} 同じ場所に2つのジャンプ。 これらはどの演算子ですか?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "コマンドIdを検索し、ブランチにラベルを付けます。" -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "ディバイド÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "乗算×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · 呼び出された関数は何をする?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5838,24 +5838,24 @@ msgid "" "value goes in?{ph1}”" msgstr "「100は明らかです。 呼び出しは単なるアドレスです。 {ph0}どちらが分かれていますか? どの値が入りますか?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "ヘルパー関数を検査し、格納されている値をトレースします。" -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "ディバイド" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03·ルールとは何ですか?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5863,19 +5863,19 @@ msgid "" "after +.”" msgstr "\"このパスは、他の入力によって乗算されます。 だから {ph0}10%は200の10%を意味します{ph1} +の後。”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "オペランドに名前を付け、両方の演算子パスを確認します。" -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "図解専門家の推論" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5885,20 +5885,20 @@ msgstr "{ph0}命令をデコードし、関数呼び出しに従って計算を #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "REA を使うと · {ph0}エージェント{ph1}に聞く" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "プロンプトは一つだけ。" #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -5912,49 +5912,49 @@ msgstr "{ph0}普段の言葉で{ph1}エージェントに質問します。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}プロンプトの例*電卓" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "回答例*調査結果に基づく" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "+の後に、%ボタンがかかります {ph0}最初の数の割合。{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA が提供する情報" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "ハンドラーの命令、逆コンパイルされたコード、および呼び出し。" -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "エージェントが説明すること" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "どのブランチが適用され、何が計算され、どのように再構築するか。" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5965,7 +5965,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5973,37 +5973,37 @@ msgid "" "Calculator and rebuild its % button." msgstr "{ph0}2つの例を試してみましょう。{ph1} ゲームの速度を変更し、電卓に戻り、その%ボタンを再構築します。" -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "例01•Chromeの恐竜ゲーム" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "恐竜はなぜ速くなる?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "ゴール" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "でゲームを再構築する {ph0}調節可能な速度。{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "なぜそれをリバースエンジニアリング?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6013,61 +6013,61 @@ msgstr "" "その加速を再現するには、次のものが必要です " "{ph0}実行中のゲームのルール{ph1}:どのくらいの速、それが増加し、それが停止したときに、それが開始されます。" -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "再構築したゲームで遊ぶ" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "スタート" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "ジャンプ ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "リスタート" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "速度" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "回復された加速ルールを使用する" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "元のルール:6から開始し、13に向かって増加します。" -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "スライダーを動かして独自の速度を設定します。 スペースまたはジャンプはサボテンをクリアします。" -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" " remain readable." msgstr "再構成を再生するには、JavaScriptを有効にします。 上記のコードと説明は読みやすいままです。" -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA が返した結果" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6084,7 +6084,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6096,7 +6096,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}例プロンプト*恐竜ゲー #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6109,7 +6109,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6121,7 +6121,7 @@ msgstr "検査対象: {ph0}wayouのChromium派生ブラウザ版{ph1}." #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6130,12 +6130,12 @@ msgid "" msgstr "" "{ph0}6時からスタート。{ph1} 追加 {ph2}0.001{ph3} 速度は以下のとおりですが、衝突のない各更新 {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "スピードラボを開く→" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "各部分が何をするか" @@ -6146,7 +6146,7 @@ msgstr "各部分が何をするか" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6161,7 +6161,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6176,7 +6176,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6185,13 +6185,13 @@ msgid "" msgstr "" "{ph0}03*あなた{ph1}{ph2}スピードとプレイを変更します{ph3}{ph4}元の加速を試してから、自分のペースを選択してください。{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "スクリプト、チェック、および分析を試す方法を参照してください" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6201,7 +6201,7 @@ msgstr "" "REAは、ローカルデバッグ接続を介してHTTP browser editionを検査し、ロードされたものを返しました " "{ph0}、そのソースとダイジェストを含みます。" -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6213,7 +6213,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6228,7 +6228,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6244,26 +6244,26 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "{ph0}検査された速度コード{ph1} · {ph2} Chromium著者·BSDライセンス{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "例02•Windows計算機" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "電卓の % ボタンを再現する。" #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6272,7 +6272,7 @@ msgstr "処理する小さな電卓を構築する {ph0}+と×の両方が正し #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6282,29 +6282,29 @@ msgstr "" "同じボタンを使用します {ph0}+と×の後の異なるルール。{ph1} " "インストールされているアプリを調べて、パーセンテージが適用されている数を見つけます。" -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "計算を試してみてください" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "リセット" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10%の200=20. 次に、200+20=220。" -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "検査されたパーセンテージルールのインタラクティブなイラスト。" #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6314,7 +6314,7 @@ msgstr "{ph0}1つの枝は100で割ります。 もう1つは、最初の数で #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6328,7 +6328,7 @@ msgstr "この例では, {ph0} と {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6342,7 +6342,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}プロンプトの例*%ボタン #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6353,7 +6353,7 @@ msgstr "" "{ph0}›{ph1}{ph2}REAを使用して、Windows計算機の%ボタンを検査します。 " "+と×の後にルールを回復し、両方を使用する小さな電卓を作成します。{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "と+" @@ -6361,13 +6361,13 @@ msgstr "と+" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}最初の数の10%を取ります。{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "×付き" @@ -6375,13 +6375,13 @@ msgstr "×付き" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}10%を0.1に変えます。{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "インストールされているアプリから作業中の%ボタンへ" @@ -6393,7 +6393,7 @@ msgstr "インストールされているアプリから作業中の%ボタン #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6410,7 +6410,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6420,12 +6420,12 @@ msgid "" msgstr "" "{ph0}03*あなたのエージェントはそれを解釈します{ph1}{ph2}ルールの説明と再現{ph3}{ph4}ブランチをMicrosoftのソースと照合してから、上記の+と×を試してください。{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "実際のコードと答えがどのようにチェックされたかを参照してください" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6445,7 +6445,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6453,29 +6453,29 @@ msgid "" "{ph4}Native setup guide{ph5}" msgstr "{ph0}割合の実装{ph1} · {ph2}マイクロソフトのテストケース{ph3} · {ph4}ネイティブセットアップガイド{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "あなたの最初の調査" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "小さなアプリでREAを試してみてください。" -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " "input." msgstr "私たちのノートの例をダウンロードし、そのCSVエクスポートをトレースし、一つの変更された入力を確認してください。" -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "ガイド付きの例を試してください" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "もっと詳しく知りたい?" @@ -6484,7 +6484,7 @@ msgstr "もっと詳しく知りたい?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6503,13 +6503,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}プロンプトの例·ウェブサイト" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "はい、これ。" @@ -6522,7 +6522,7 @@ msgstr "はい、これ。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6535,7 +6535,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}例プロンプト·ゲームの #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6548,69 +6548,69 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}DX-Ballのケーススタディを見る→{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "設定と分析の手順については、ターゲットを選択します:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "マシンコードプログラム" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" " and libraries." msgstr "実行可能ファイルおよびライブラリ内の関数、文字列、参照、および関数呼び出しの関係を検査します。" -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "機械コードの解析プログラムガイド" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript&Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " "or ASAR archive." msgstr "アプリケーションフォルダまたはASARアーカイブからモジュール、ルート、IPCおよびネイティブ依存関係をマップします。" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "アプリケーションワークフロー" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "ブラウザとランタイムアクティビティ" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " "across runs." msgstr "選択したブラウザまたはプロセスアクティビティをキャプチャし、実行間で結果を比較します。" -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "ブラウザ観察ガイド" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "質問がありますか?" @@ -6621,7 +6621,7 @@ msgstr "質問がありますか?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6631,12 +6631,12 @@ msgstr "" "{ph0}よくある質問を読む{ph1}, {ph2}Discordで私たちとチャット {ph3}、または {ph4}GitHubの問題を開く{ph5} " "バグや機能要求のために。" -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "コミュニティに参加" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6657,59 +6657,59 @@ msgstr "" "コーディングエージェントを使用して、アプリ、バイナリ、ブラウザの動作をリバースエンジニアリングします。 " "明確なガイドに従って、REAを使用してインタラクティブな例を試してください。" -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "コピー REAインストールプロンプト" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Copy setupコマンド" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "手動計算機の分析に従ってください" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "コピー電卓パーセントの質問" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " "focused." msgstr "恐竜ミニゲーム。 ゲームがフォーカスされている間にジャンプボタンを使用するか、スペースを押します。" -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "コピー恐竜解析プロンプト" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "計算を選択する" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "コピー電卓分析プロンプト" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "ウェブサイトプロジェクトプロンプトをコピーする" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "コピーゲーム再構築プロンプト" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "ページ上部へ" @@ -10885,26 +10885,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "同じ間隔:256÷16=16角度単位=22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "事例" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -10922,8 +10902,8 @@ msgstr "事例" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "ブログ" +msgid "Showcases" +msgstr "事例" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -10942,6 +10922,26 @@ msgstr "ブログ" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "ブログ" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "はじめる" @@ -10974,7 +10974,7 @@ msgstr "ガイド" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -10994,7 +10994,7 @@ msgstr "ソースコード" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11014,7 +11014,7 @@ msgstr "MIT ライセンス" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11034,7 +11034,7 @@ msgstr "コミュニティに参加" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11145,37 +11145,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "REA の使い方を見る ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "セットアップガイド" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "ショーケース→" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "検査されたスクリプトから選択された設定" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "%ハンドラーの読み取り可能な要約" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA*選択されたオリジナルの指示" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "オペレーター Id·Microsoftのソース" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Xの@reatoolsに従ってください" @@ -11581,7 +11576,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11599,7 +11594,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11813,7 +11808,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11825,7 +11820,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -11838,7 +11833,7 @@ msgstr "例えば:{ph0}なぜ電卓で {ph1} を計算すると 220 になる #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11849,7 +11844,7 @@ msgstr "" "220を与えますか?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -11858,7 +11853,7 @@ msgstr "REA·ロード {ph0} *選択した行" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11875,7 +11870,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11932,6 +11927,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "メイン/{ph0} *クリップボード受信機" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "はじめる {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "セットアップガイド" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/ko/messages.po b/website/locales/ko/messages.po index ddc126c9d2..457998e02c 100644 --- a/website/locales/ko/messages.po +++ b/website/locales/ko/messages.po @@ -71,7 +71,7 @@ msgstr "여N0zoM1z0·10 월 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5549,7 +5549,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "이 페이지에" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "설정REA" @@ -5647,7 +5647,7 @@ msgstr "REA는 코딩 에이전트가 프로그램을 조사하고 동작을 설 #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5655,13 +5655,13 @@ msgstr "이미 다시 알고? {ph0}분석 가이드로 건너뛰기.{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "더 깊이 가고 싶습니까? {ph0}블로그 읽기→{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "이 프롬프트를 코딩 에이전트에 붙여 넣으세요:" @@ -5674,38 +5674,38 @@ msgstr "이 프롬프트를 코딩 에이전트에 붙여 넣으세요:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}코딩 에이전트" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "복사" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "터미널에서 직접 실행할 수도 있습니다:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "설정 변경 계획을 확인하고 승인한 뒤 에이전트를 다시 시작하세요." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "리버스 엔지니어링이란?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5718,7 +5718,7 @@ msgstr "{ph0}프로그램 자체를 조사해서{ph1} 소프트웨어의 작동 #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5728,7 +5728,7 @@ msgstr "목표:충분히 기능을 이해 {ph0}그것을 설명{ph1}, {ph2}변 #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -5736,7 +5736,7 @@ msgstr "REA 사용 전 · {ph0}직접{ph1} 조사합니다" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -5744,7 +5744,7 @@ msgstr "{ph0}01{ph1} 분기 디코딩" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -5752,7 +5752,7 @@ msgstr "{ph0}02{ph1} 추적 호출" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -5762,20 +5762,20 @@ msgstr "{ph0}03{ph1} 규칙 복구" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}원본 64*선택한 지침{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01*어떤 지점?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5783,29 +5783,29 @@ msgid "" "these?”" msgstr "“{ph0}? -그래?{ph1} 같은 장소에 두 점프. 이들은 어떤 연산자입니까?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "명령 아이디를 조회 한 다음 브랜치에 레이블을 지정하십시오." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "분÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "곱하기×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · 호출된 함수는 무엇을 하나요?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5813,24 +5813,24 @@ msgid "" "value goes in?{ph1}”" msgstr "100 은 분명하다. 전화는 단지 주소 일뿐입니다. {ph0}어느 쪽이 나눌까요? 어떤 값이 들어가나요?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "도우미 함수를 검사하고 저장된 값을 추적합니다." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "나누기" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03*규칙은 무엇입니까?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5838,19 +5838,19 @@ msgid "" "after +.”" msgstr "\"이 경로는 다른 입력으로 곱합니다. 그래서 {ph0}10%는 200 의 10%를 의미합니다.{ph1} +후”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "피연산자의 이름을 지정하고 두 연산자 경로를 모두 확인하십시오." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "예시 전문가 추론" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5860,20 +5860,20 @@ msgstr "{ph0}당신은 명령을 디코딩 함수 호출을 따라 계산을 재 #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "REA 사용 시 · {ph0}에이전트{ph1}에게 묻습니다" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "프롬프트 하나면 됩니다." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -5887,49 +5887,49 @@ msgstr "에이전트에게 {ph0}일상적인 말로{ph1} 질문하세요." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}예제 프롬프트*계산기" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "예제 답변·결과에 따라" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "+후,%버튼이 걸립니다 {ph0}첫 번째 숫자의 백분율.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA가 제공하는 정보" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "핸들러의 명령,디 컴파일 된 코드 및 호출." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "에이전트가 설명하는 내용" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "어떤 분기가 적용되는지,무엇을 계산하고 다시 작성하는 방법." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5939,7 +5939,7 @@ msgstr "설치된 계산기에서 선택한 어셈블리. 생각과 대답은 #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5947,37 +5947,37 @@ msgid "" "Calculator and rebuild its % button." msgstr "{ph0}두 가지 예를 들어 보겠습니다.{ph1} 게임의 속도를 변경 한 다음 계산기로 돌아가서%버튼을 다시 작성하십시오." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "예 01·Chrome's 룡 게임" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "공룡은 왜 점점 빨라질까요?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "목표" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "와 게임을 다시 {ph0}조정 가능한 속도.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "왜 그것을 리버스 엔지니어링합니까?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5985,61 +5985,61 @@ msgid "" "game{ph1}: how fast it starts, how much it increases and when it stops." msgstr "가속도를 재현하려면 {ph0}실행중인 게임의 규칙{ph1}:얼마나 빨리 시작,얼마나 증가하고 멈출 때." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "재구성한 게임 플레이" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "시작" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "점프 ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "다시 시작" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "속도" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "복구된 가속 규칙 사용" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "원래 규칙:6 에서 시작한 다음 13 으로 늘립니다." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "자신의 속도를 설정하는 슬라이더를 이동합니다. 공간 또는 점프는 선인장을 지 웁니다." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" " remain readable." msgstr "사용JavaScript을 재생하는 재건. 위의 코드와 설명은 읽을 수 있습니다." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA가 반환한 결과" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6057,7 +6057,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6069,7 +6069,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}예제 프롬프트*공룡 게 #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6082,7 +6082,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6094,7 +6094,7 @@ msgstr "검사 대상: {ph0}와이우의 Chromium-파생 브라우저 에디션{ #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6103,12 +6103,12 @@ msgid "" msgstr "" "{ph0}시작 6.{ph1} 추가 {ph2}0.001{ph3} 충돌없이 각 업데이트,속도가 아래에있는 동안 {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "스피드 랩을 엽니다." -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "각 부분이 하는 일" @@ -6119,7 +6119,7 @@ msgstr "각 부분이 하는 일" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6135,7 +6135,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6151,7 +6151,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6160,13 +6160,13 @@ msgid "" msgstr "" "{ph0}03*당신{ph1}{ph2}속도 변경 및 재생{ph3}{ph4}원래 가속을 시도한 다음 자신의 페이스를 선택하십시오.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "스크립트,검사 및 분석 시도 방법을 참조하십시오" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6176,7 +6176,7 @@ msgstr "" "REA 로컬 디버깅 연결을 통해 브라우저 버전을 검사하고 로드된 버전을 반환했습니다. {ph0},그 원천과 다이제스트를 " "포함합니다." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6188,7 +6188,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6203,7 +6203,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6219,26 +6219,26 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "{ph0}검사된 속도 코드{ph1} · {ph2}Chromium 저자*비스 디 라이센스{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "예 02·Windows계산기" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "계산기의 % 버튼을 재현해 봅니다." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6247,7 +6247,7 @@ msgstr "처리하는 작은 계산기를 구축 {ph0}+및 2018 년 11 월 13 일 #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6257,29 +6257,29 @@ msgstr "" "같은 버튼 사용 {ph0}+및 2019 년 이후의 다른 규칙.{ph1} 우리는 비율이 적용되는 번호를 찾기 위해 설치된 응용 프로그램을 " "검사합니다." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "계산 시도" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "초기화" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10%의 200=20. 그런 다음 200+20=220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "검사된 백분율 규칙의 대화형 그림입니다." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6291,7 +6291,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6305,7 +6305,7 @@ msgstr "이 예제에서는, {ph0} 그리고 {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6319,7 +6319,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}예제 프롬프트*%단추 다 #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6330,7 +6330,7 @@ msgstr "" "{ph0}›{ph1}{ph2}사용 REA 검사 Windows 계산기의%버튼. +와 200 이후의 규칙을 복구 한 다음 둘 다 사용하는 " "작은 계산기를 만듭니다.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "+와 함께" @@ -6338,13 +6338,13 @@ msgstr "+와 함께" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}첫 번째 숫자의 10%를 가져 가라.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "와×" @@ -6352,13 +6352,13 @@ msgstr "와×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}10%를 0.1 로 바꿉니다.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "설치된 앱에서 작동%버튼으로" @@ -6370,7 +6370,7 @@ msgstr "설치된 앱에서 작동%버튼으로" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6387,7 +6387,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6398,12 +6398,12 @@ msgstr "" "{ph0}03*당신의 에이전트는 그것을 해석{ph1}{ph2}규칙 설명 및 재현{ph3}{ph4}마이크로 소프트의 소스와 지점을 교차 " "검사 한 다음 위의+및 200 을 시도하십시오.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "실제 코드 및 답변 확인 방법 보기" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6423,7 +6423,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6431,29 +6431,29 @@ msgid "" "{ph4}Native setup guide{ph5}" msgstr "{ph0}백분율 구현{ph1} · {ph2}마이크로소프트의 테스트 케이스{ph3} · {ph4}기본 설정 가이드{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "첫 번째 조사" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "시도해봐 REA 작은 응용 프로그램에." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " "input." msgstr "우리의 노트 예제를 다운로드,그 추적 CSV 수출,하나의 변경된 입력을 확인합니다." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "가이드 예제를 사용해보십시오" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "더 깊이 알아보고 싶나요?" @@ -6462,7 +6462,7 @@ msgstr "더 깊이 알아보고 싶나요?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6481,13 +6481,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}프롬프트 예*웹 사이트" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "예,이 하나." @@ -6500,7 +6500,7 @@ msgstr "예,이 하나." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6513,7 +6513,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}예제 프롬프트*게임 재 #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6526,69 +6526,69 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}보 DX-Ball 사례 연구→{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "설정 및 분석 단계에서 대상을 선택합니다:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "기계 코드 프로그램" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" " and libraries." msgstr "실행 파일 및 라이브러리에서 함수,문자열,참조 및 함수 호출 관계를 검사합니다." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "기계 코드 프로그램 분석 가이드" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " "or ASAR archive." msgstr "응용 프로그램 폴더에서 모듈,경로 및 기본 종속성을 매핑하거나 ASAR 아카이브" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "애플리케이션 워크플로" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "브라우저 및 런타임 활동" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " "across runs." msgstr "선택한 브라우저 또는 프로세스 활동을 캡처 한 다음 여러 실행에서 결과를 비교합니다." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "브라우저 관찰 가이드" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "궁금한 점이 있나요?" @@ -6599,7 +6599,7 @@ msgstr "궁금한 점이 있나요?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6609,12 +6609,12 @@ msgstr "" "{ph0}자주 묻는 질문 읽기{ph1}, {ph2}에 우리와 함께 채팅 Discord{ph3},또는 {ph4}오픈 GitHub " "문제{ph5} 버그 및 기능 요청." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "커뮤니티 참여" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6635,59 +6635,59 @@ msgstr "" "코딩 에이전트를 사용하여 앱,바이너리 및 브라우저 동작을 리버스 엔지니어링합니다. 명확한 가이드를 따라 대화 형 예제를 사용해보십시오 " "REA." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "복사REA설치 프롬프트" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "설치 명령 복사" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "수동 계산기 분석을 따르십시오" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "복사 계산기 백분율 질문" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " "focused." msgstr "공룡 미니 게임. 게임이 집중되는 동안 점프 버튼을 누르거나 공간을 사용합니다." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "복사 공룡 분석 프롬프트" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "계산 선택" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "복사 계산기 분석 프롬프트" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "웹 사이트 프로젝트 프롬프트 복사" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "복사 게임 재건 프롬프트" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "맨 위로" @@ -10851,26 +10851,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "같은 간격:256÷16=16 각도 단위=22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "사례" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -10888,8 +10868,8 @@ msgstr "사례" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "블로그" +msgid "Showcases" +msgstr "사례" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -10908,6 +10888,26 @@ msgstr "블로그" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "블로그" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "시작하기" @@ -10940,7 +10940,7 @@ msgstr "가이드" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -10960,7 +10960,7 @@ msgstr "소스 코드" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -10980,7 +10980,7 @@ msgstr "MIT 라이선스" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11000,7 +11000,7 @@ msgstr "커뮤니티 참여" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11111,37 +11111,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "REA 사용 방식 보기 ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "설정 가이드" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "전시→" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "검사된 스크립트에서 선택한 설정" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "%처리기의 읽을 수 있는 요약" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA·선택한 원래의 지침" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "운영자의 신분증*마이크로 소프트의 소스" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "쨩챌쨔챈쨀" @@ -11548,7 +11543,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11566,7 +11561,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11780,7 +11775,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11791,7 +11786,7 @@ msgstr "처음 또는 누락 {ph0} 아니면 코딩 에이전트? {ph1}단계별 #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -11804,7 +11799,7 @@ msgstr "예를 들어: {ph0}계산기에서 {ph1}가 왜 220이 될까요?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11813,7 +11808,7 @@ msgid "" msgstr "{ph0}›{ph1}{ph2}사용 REA 검사 Windows 계산기 왜 {ph3} 220 줘?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -11822,7 +11817,7 @@ msgstr "REA *로드 {ph0} *선택한 라인" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11839,7 +11834,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11896,6 +11891,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "주요/{ph0} *클립 보드 수신기" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "시작하기 {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "설정 가이드" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "WindowsPowerShell" diff --git a/website/locales/messages.pot b/website/locales/messages.pot index e6057ae01e..da3276762d 100644 --- a/website/locales/messages.pot +++ b/website/locales/messages.pot @@ -25,7 +25,7 @@ msgstr "" #: website/public/showcase/notion/index.html:118 #: website/public/showcase/th04/index.html:118 msgctxt "shared" -msgid "Showcases" +msgid "Get started" msgstr "" #: website/public/blog/index.html:119 @@ -42,7 +42,7 @@ msgstr "" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" +msgid "Showcases" msgstr "" #: website/public/blog/index.html:120 @@ -59,7 +59,7 @@ msgstr "" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" -msgid "Get started" +msgid "Blog" msgstr "" #: website/public/blog/index.html:121 @@ -133,7 +133,7 @@ msgstr "" #: website/public/get-started/index.html:870 #: website/public/guides/browser/index.html:486 website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 website/public/showcase/index.html:305 @@ -150,7 +150,7 @@ msgstr "" #: website/public/get-started/index.html:871 #: website/public/guides/browser/index.html:487 website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 website/public/showcase/index.html:306 @@ -167,7 +167,7 @@ msgstr "" #: website/public/get-started/index.html:872 #: website/public/guides/browser/index.html:488 website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 website/public/showcase/index.html:307 @@ -184,7 +184,7 @@ msgstr "" #: website/public/get-started/index.html:873 #: website/public/guides/browser/index.html:489 website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 website/public/showcase/index.html:308 @@ -203,7 +203,7 @@ msgstr "" #: website/public/get-started/index.html:878 #: website/public/guides/browser/index.html:494 website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 website/public/showcase/index.html:313 @@ -5503,7 +5503,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "" @@ -5602,19 +5602,21 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "" +#. {ph0}: #: website/public/index.html:229 +#, python-brace-format msgctxt "page:index.html" -msgid "Setup guide" +msgid "Get started {ph0}" msgstr "" -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5622,13 +5624,13 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "" @@ -5641,16 +5643,16 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "" @@ -5662,7 +5664,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5670,12 +5672,12 @@ msgid "" "the setup plan for approval, then verify the installation.{ph4}{ph5}{ph6}" msgstr "" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "" @@ -5683,7 +5685,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5691,14 +5693,14 @@ msgid "" "guide →{ph2}" msgstr "" -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "Finding out how software works by {ph0}examining the program itself.{ph1}" @@ -5710,7 +5712,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5721,7 +5723,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -5729,7 +5731,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -5737,7 +5739,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -5745,7 +5747,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -5753,7 +5755,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -5763,48 +5765,48 @@ msgstr "" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "“{ph0}0x5b? 0x5c?{ph1} Two jumps to the same place. Which operators are these?”" msgstr "" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "" -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5812,43 +5814,43 @@ msgid "" "in?{ph1}”" msgstr "" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "" -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" "“This path multiplies by the other input. So {ph0}10% means 10% of 200{ph1} after +.”" msgstr "" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "" -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5858,20 +5860,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -5885,7 +5887,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" @@ -5898,7 +5900,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5906,42 +5908,42 @@ msgid "" "220?{ph4}{ph5}{ph6}" msgstr "" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "" -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5951,7 +5953,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5959,37 +5961,37 @@ msgid "" " rebuild its % button." msgstr "" -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5997,61 +5999,61 @@ msgid "" "fast it starts, how much it increases and when it stops." msgstr "" -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "" -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above remain " "readable." msgstr "" -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6067,7 +6069,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6079,7 +6081,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6089,14 +6091,14 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." msgstr "" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -6108,7 +6110,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6116,12 +6118,12 @@ msgid "" " is below {ph4}13{ph5}." msgstr "" -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "" @@ -6132,7 +6134,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6146,7 +6148,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6160,7 +6162,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6168,13 +6170,13 @@ msgid "" "acceleration, then choose your own pace.{ph5}" msgstr "" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6182,12 +6184,12 @@ msgid "" "returned the loaded {ph0}, including its source and digest." msgstr "" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "" -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check, with " @@ -6197,7 +6199,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6210,7 +6212,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6224,18 +6226,18 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "" @@ -6243,7 +6245,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6253,7 +6255,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "Build a small calculator that handles {ph0}both + and × correctly.{ph1}" @@ -6261,7 +6263,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6269,29 +6271,29 @@ msgid "" " app to find which number the percentage is applied to." msgstr "" -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "" -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6301,7 +6303,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6315,7 +6317,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Rebuild the % button" @@ -6327,7 +6329,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6335,12 +6337,12 @@ msgid "" "after + and ×, then build a small calculator that uses both.{ph3}{ph4}{ph5}" msgstr "" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "" @@ -6348,13 +6350,13 @@ msgstr "" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "" @@ -6362,13 +6364,13 @@ msgstr "" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "" @@ -6381,7 +6383,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6396,7 +6398,7 @@ msgstr "" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6410,7 +6412,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6419,22 +6421,22 @@ msgid "" "above.{ph5}" msgstr "" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable summary " @@ -6451,7 +6453,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6459,27 +6461,27 @@ msgid "" "setup guide{ph5}" msgstr "" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "" -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "Download our Notes example, trace its CSV export, and check one changed input." msgstr "" -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "" @@ -6488,7 +6490,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6505,7 +6507,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" @@ -6518,14 +6520,14 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}›{ph1}{ph2}Use REA to inspect {ph3}. Clone this website for me.{ph4}{ph5}{ph6}" msgstr "" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "" @@ -6538,7 +6540,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Game reconstruction" @@ -6550,7 +6552,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6560,68 +6562,68 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables and " "libraries." msgstr "" -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder or ASAR " "archive." msgstr "" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results across runs." msgstr "" -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "" @@ -6632,7 +6634,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6640,19 +6642,19 @@ msgid "" "issue{ph5} for bugs and feature requests." msgstr "" -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow REA on X for" " project updates." msgstr "" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "" @@ -6669,58 +6671,58 @@ msgid "" "clear guides and try interactive examples with REA." msgstr "" -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is focused." msgstr "" -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "" diff --git a/website/locales/pl/messages.po b/website/locales/pl/messages.po index a4d7d37f71..05052a1692 100644 --- a/website/locales/pl/messages.po +++ b/website/locales/pl/messages.po @@ -73,7 +73,7 @@ msgstr "Przez N0zoM1z0 * 10 października 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5975,7 +5975,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Na tej stronie" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Skonfiguruj REA" @@ -6075,7 +6075,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6083,13 +6083,13 @@ msgstr "Znasz już RE? {ph0}Przejdź do poradników analizy →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Chcesz wejść głębiej? {ph0}Czytaj Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Wklej to polecenie do swojego agenta programistycznego:" @@ -6102,38 +6102,38 @@ msgstr "Wklej to polecenie do swojego agenta programistycznego:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Twój agent kodowania" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Kopiuj" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Albo uruchom to polecenie w terminalu:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Sprawdź i zatwierdź plan zmian, a następnie uruchom agenta ponownie." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Czym jest inżynieria wsteczna?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6148,7 +6148,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6160,7 +6160,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6168,7 +6168,7 @@ msgstr "Przed REA · Badasz program {ph0}samodzielnie{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6176,7 +6176,7 @@ msgstr "{ph0}01{ph1} Dekoduj gałęzie" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6184,7 +6184,7 @@ msgstr "{ph0}02{ph1} Śledzenie połączeń" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6194,20 +6194,20 @@ msgstr "{ph0}03{ph1} Odzyskaj regułę" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Oryginalny x64 * wybrane instrukcje{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * która gałąź?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6216,29 +6216,29 @@ msgid "" msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Dwa skoki w to samo miejsce. Którzy to operatorzy?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Wyszukaj identyfikatory poleceń, a następnie oznacz gałąź." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Podziel ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Mnożenie ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Co robią wywoływane funkcje?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6248,24 +6248,24 @@ msgstr "" "\"100 jest jasne. Rozmowy to tylko adresy. {ph0}Który dzieli? Która wartość " "wchodzi?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Sprawdź funkcje pomocnicze i prześledź zapisane wartości." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Divide" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * Jaka jest reguła?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6275,19 +6275,19 @@ msgstr "" "\"Ta ścieżka mnoży się przez inne dane wejściowe. Więc {ph0}10% oznacza 10% " "z 200{ph1} po +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Nazwij operandy i sprawdź obie ścieżki operatora." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Ilustrujące rozumowanie eksperta" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6299,20 +6299,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Z REA · Pytasz {ph0}swojego agenta{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Wystarczy jedno polecenie." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6326,50 +6326,50 @@ msgstr "Zadaj agentowi pytanie {ph0}zwykłymi słowami.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Przykładowy monit * Kalkulator" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Przykładowa odpowiedź · na podstawie ustaleń" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "Po +, przycisk % trwa {ph0}procent pierwszej liczby.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA dostarcza" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Instrukcje obsługi, zdekompilowany kod i połączenia." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Twój agent wyjaśnia" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Która gałąź ma zastosowanie, co oblicza i jak ją odbudować." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6381,7 +6381,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6391,37 +6391,37 @@ msgstr "" "{ph0}Spróbujmy dwóch przykładów.{ph1} Zmień szybkość gry, a następnie wróć " "do kalkulatora i odbuduj przycisk%." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Przykład 01 * gra dinozaurów Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Dlaczego dinozaur przyspiesza?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Gol" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Odbuduj grę za pomocą {ph0}regulowana prędkość.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Dlaczego reverse engineer it?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6432,49 +6432,49 @@ msgstr "" "biegowej{ph1}: jak szybko się zaczyna, jak bardzo się zwiększa i kiedy się " "zatrzymuje." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Zagraj w odtworzoną wersję" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Graj" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Skok ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Zacznij od nowa" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Prędkość" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Użyj reguły odzyskiwania przyspieszenia" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Pierwotna zasada: zacznij od 6, a następnie zwiększ do 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Przesuń suwak, aby ustawić własną prędkość. Przestrzeń lub skok usuwa " "kaktusa." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6483,14 +6483,14 @@ msgstr "" "Włącz JavaScript, aby odtworzyć rekonstrukcję. Powyższy kod i Wyjaśnienie " "pozostają czytelne." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA zwróciło" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6509,7 +6509,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6522,7 +6522,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6536,7 +6536,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6549,7 +6549,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6559,12 +6559,12 @@ msgstr "" "{ph0}Zacznij od 6.{ph1} Dodaj {ph2}0.001{ph3} każda aktualizacja bez " "kolizji, podczas gdy prędkość jest poniżej {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Otwórz speed lab" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Co robi każda część" @@ -6575,7 +6575,7 @@ msgstr "Co robi każda część" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6591,7 +6591,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6607,7 +6607,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6617,13 +6617,13 @@ msgstr "" "{ph0}03 * Ty{ph1}{ph2}Zmień prędkość i graj{ph3}{ph4}Wypróbuj oryginalne " "przyspieszenie, a następnie wybierz własne tempo.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Zobacz skrypt, kontrole i jak wypróbować analizę" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6633,7 +6633,7 @@ msgstr "" "REA sprawdził wersję przeglądarki HTTP poprzez lokalne połączenie " "debugowania i zwrócił załadowany {ph0}, w tym jego źródło i trawienie." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6647,7 +6647,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6663,7 +6663,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6681,7 +6681,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6690,19 +6690,19 @@ msgstr "" "{ph0}Sprawdzony kod prędkości{ph1} · {ph2} Chromium autorzy * licencja " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Przykład 02 * Kalkulator Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Odtwórz przycisk % kalkulatora." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6713,7 +6713,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6724,29 +6724,29 @@ msgstr "" "zainstalowaną aplikację, aby dowiedzieć się, do której liczby zastosowany " "jest procent." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Wypróbuj obliczenia" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Resetuj" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% z 200 = 20. Następnie 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Interaktywna ilustracja kontrolowanej reguły procentowej." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6758,7 +6758,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6772,7 +6772,7 @@ msgstr "Dla tego przykładu, {ph0} oraz {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6788,7 +6788,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6800,7 +6800,7 @@ msgstr "" "Odzyskaj reguły po + I×, a następnie zbuduj mały kalkulator, który używa " "obu.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Z +" @@ -6808,13 +6808,13 @@ msgstr "Z +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Weź 10% pierwszej liczby.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Z ×" @@ -6822,13 +6822,13 @@ msgstr "Z ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Zamień 10% na 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Z zainstalowanej aplikacji do działającego przycisku %" @@ -6840,7 +6840,7 @@ msgstr "Z zainstalowanej aplikacji do działającego przycisku %" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6857,7 +6857,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6869,12 +6869,12 @@ msgstr "" "ZASADY{ph3}{ph4}Sprawdź gałąź za pomocą źródła Microsoft, a następnie " "wypróbuj + i × powyżej.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Zobacz prawdziwy kod i sposób sprawdzenia odpowiedzi" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6895,7 +6895,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6905,17 +6905,17 @@ msgstr "" "{ph0}Procentowa realizacja{ph1} · {ph2}Przypadki testowe Microsoftu{ph3} · " "{ph4}Natywny Przewodnik konfiguracji{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Twoje pierwsze śledztwo" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Wypróbuj REA w małej aplikacji." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6924,12 +6924,12 @@ msgstr "" "Pobierz nasz przykład notatek, prześledź eksport CSV i sprawdź jedno " "zmienione wejście." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Wypróbuj przykład z przewodnikiem" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Chcesz dowiedzieć się więcej?" @@ -6938,7 +6938,7 @@ msgstr "Chcesz dowiedzieć się więcej?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6958,7 +6958,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" @@ -6966,7 +6966,7 @@ msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Przykładowy monit * strona " "internetowa" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Tak, ten." @@ -6979,7 +6979,7 @@ msgstr "Tak, ten." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6994,7 +6994,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7008,23 +7008,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Zobacz studium przypadku DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Aby wykonać kroki konfiguracji i analizy, wybierz cel:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Programy do kodu maszynowego" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7033,17 +7033,17 @@ msgstr "" "Sprawdzaj funkcje, ciągi znaków, odwołania i relacje wywołania funkcji w " "plikach wykonywalnych i bibliotekach." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Analiza programów kodu maszynowego przewodnik" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7052,17 +7052,17 @@ msgstr "" "Mapuj Moduły, trasy, IPC i natywne zależności z folderu aplikacji lub " "archiwum ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Przepływy pracy aplikacji" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Aktywność przeglądarki i środowiska wykonawczego" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7071,12 +7071,12 @@ msgstr "" "Przechwyć wybraną aktywność przeglądarki lub procesu, a następnie porównaj " "wyniki w różnych przebiegach." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Przewodnik obserwacji przeglądarki" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Masz pytania?" @@ -7087,7 +7087,7 @@ msgstr "Masz pytania?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7097,12 +7097,12 @@ msgstr "" "{ph0}Przeczytaj FAQ{ph1}, {ph2}porozmawiaj z nami na Discord {ph3}, lub " "{ph4}otwórz problem GitHub{ph5} dla błędów i żądań funkcji." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Dołącz do społeczności" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7126,27 +7126,27 @@ msgstr "" "agentem kodującym. Postępuj zgodnie z przejrzystymi przewodnikami i wypróbuj" " interaktywne przykłady z REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Skopiuj monit instalacji REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Skopiuj polecenie konfiguracji" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Postępuj zgodnie z ręczną analizą kalkulatora" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Kopiuj Kalkulator Procent pytanie" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7155,32 +7155,32 @@ msgstr "" "Dinozaur mini-gry. Użyj przycisku skoku lub naciśnij spację, gdy gra jest " "skupiona." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Skopiuj monit o analizę dinozaurów" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Wybierz obliczenia" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Kopiuj monit o analizę kalkulatora" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Skopiuj monit projektu witryny" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Skopiuj monit o rekonstrukcję gry" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Do góry" @@ -11617,26 +11617,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Ten sam odstęp: 256 ÷ 16 = 16 jednostek kąta = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Przykłady" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11654,8 +11634,8 @@ msgstr "Przykłady" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Przykłady" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11674,6 +11654,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Pierwsze kroki" @@ -11706,7 +11706,7 @@ msgstr "Poradniki" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11726,7 +11726,7 @@ msgstr "Kod źródłowy" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11746,7 +11746,7 @@ msgstr "Licencja MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11766,7 +11766,7 @@ msgstr "Dołącz do społeczności" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11877,37 +11877,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Zobacz, jak działa REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Instrukcja instalacji" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Gabloty →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Wybrane ustawienia z kontrolowanego skryptu" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Czytelne podsumowanie % obsługi" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * wybrane oryginalne instrukcje" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Identyfikatory operatora * Źródło Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "@ Reatools na X" @@ -12318,7 +12313,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12337,7 +12332,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12568,7 +12563,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12581,7 +12576,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12594,7 +12589,7 @@ msgstr "Na przykład: {ph0}dlaczego kalkulator zwraca 220 dla {ph1}?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12605,7 +12600,7 @@ msgstr "" "daje 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12614,7 +12609,7 @@ msgstr "REA * załadowany {ph0} * wybrane linie" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12632,7 +12627,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12691,6 +12686,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "główna/{ph0} * odbiornik schowka" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Pierwsze kroki {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Instrukcja instalacji" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/pt-BR/messages.po b/website/locales/pt-BR/messages.po index eed7bacd2f..82d15f9a21 100644 --- a/website/locales/pt-BR/messages.po +++ b/website/locales/pt-BR/messages.po @@ -73,7 +73,7 @@ msgstr "Por N0zoM1z0 * 10 de outubro de 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6019,7 +6019,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Nesta página" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Configurar REA" @@ -6119,7 +6119,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6127,13 +6127,13 @@ msgstr "Já conhece o RE? {ph0}Ir para os guias de análise{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Quer ir mais fundo? {ph0}Leia o blogue{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Copie este prompt para seu agente de programação:" @@ -6146,38 +6146,38 @@ msgstr "Copie este prompt para seu agente de programação:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}O seu agente de codificação" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Copiar" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Ou execute este comando no terminal:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Revise e aprove as alterações propostas. Depois, reinicie seu agente." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "O que é engenharia reversa?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6191,7 +6191,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6203,7 +6203,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6211,7 +6211,7 @@ msgstr "Antes do REA · Você investiga {ph0}por conta própria{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6219,7 +6219,7 @@ msgstr "{ph0}01{ph1} Descodificar ramificações" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6227,7 +6227,7 @@ msgstr "{ph0}02{ph1} Rastrear chamadas" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6237,20 +6237,20 @@ msgstr "{ph0}03{ph1} Recuperar a regra" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Original x64 · instruções seleccionadas{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * que ramo?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6260,29 +6260,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Dois saltos para o mesmo lugar. Que operadores são " "estes?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Procure os IDs do comando e, em seguida, rotule a ramificação." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Dividir ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Multiplicar ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · O que fazem as funções chamadas?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6292,24 +6292,24 @@ msgstr "" "\"100 é claro. As chamadas são apenas endereços. {ph0}Qual deles divide? Que" " valor entra?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Inspecione as funções auxiliares e rastreie os valores armazenados." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Dividir" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * Qual é a regra?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6319,19 +6319,19 @@ msgstr "" "\"Este caminho multiplica-se pela outra entrada. Então {ph0}10% significa " "10% de 200{ph1} depois de +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Nomeie os operandos e verifique os dois caminhos do operador." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Raciocínio ilustrativo de peritos" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6343,20 +6343,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Com o REA · Você pergunta ao {ph0}seu agente{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Um único prompt." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6370,21 +6370,21 @@ msgstr "Pergunte ao seu agente {ph0}com palavras simples.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Exemplo de alerta * Calculadora" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Exemplo de resposta * com base nas conclusões" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6392,29 +6392,29 @@ msgid "" msgstr "" "Depois de+, o botão % leva {ph0}uma percentagem do primeiro número.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "O REA fornece" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Instruções do manipulador, código descompilado e chamadas." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Seu agente explica" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Qual ramificação se aplica, o que calcula e como reconstruí-la." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6427,7 +6427,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6437,37 +6437,37 @@ msgstr "" "{ph0}Vamos tentar dois exemplos.{ph1} Altere a velocidade de um jogo, depois" " volte à calculadora e reconstrua o botão%." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Exemplo 01 * jogo de dinossauros de Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Por que o dinossauro fica mais rápido?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Objectivo" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Reconstrua o jogo com {ph0}velocidade ajustável.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Por que fazer engenharia reversa?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6477,49 +6477,49 @@ msgstr "" "Para reproduzir sua aceleração, precisamos {ph0}a regra no jogo de " "corrida{ph1}: quão rápido começa, quanto aumenta e quando pára." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Jogue a reconstrução" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Jogar" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Pular ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Reiniciar" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Velocidade" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Use a regra de aceleração recuperada" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Regra Original: comece em 6, depois aumente para 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Mova o controle deslizante para definir sua própria velocidade. Espaço ou " "salto limpa um cacto." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6528,14 +6528,14 @@ msgstr "" "Permitir que o JavaScript reproduza a reconstrução. O código e a explicação " "acima permanecem legíveis." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "O REA retornou" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6554,7 +6554,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6568,7 +6568,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6582,7 +6582,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6596,7 +6596,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6606,12 +6606,12 @@ msgstr "" "{ph0}Comece às 6.{ph1} Adicionar {ph2}0.001{ph3} cada atualização sem " "colisão, enquanto a velocidade é inferior {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Abrir o laboratório de velocidade" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "O que cada parte faz" @@ -6622,7 +6622,7 @@ msgstr "O que cada parte faz" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6638,7 +6638,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6655,7 +6655,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6665,13 +6665,13 @@ msgstr "" "{ph0}03 * Você{ph1}{ph2}Alterar a velocidade e jogar{ph3}{ph4}Experimente a " "aceleração original e escolha o seu próprio ritmo.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Veja o script, verificações e como tentar a análise" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6681,7 +6681,7 @@ msgstr "" "O REA inspecionou a edição do navegador HTTP por meio de uma conexão de " "depuração local e retornou {ph0}, incluindo a sua fonte e digestão." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6695,7 +6695,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6712,7 +6712,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6730,7 +6730,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6739,19 +6739,19 @@ msgstr "" "{ph0}Código de velocidade inspeccionado{ph1} · {ph2} Chromium autores * " "licença BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Exemplo 02 * Calculadora Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Recrie o botão % da calculadora." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6762,7 +6762,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6773,29 +6773,29 @@ msgstr "" " o aplicativo instalado para descobrir a qual número a porcentagem é " "aplicada." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Tente o cálculo" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Redefinir" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% de 200 = 20. Então 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Ilustração interactiva da regra das percentagens inspeccionadas." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6807,7 +6807,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6821,7 +6821,7 @@ msgstr "Para este exemplo, {ph0} e {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6837,7 +6837,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6849,7 +6849,7 @@ msgstr "" "Recupere as regras depois de + E7X, em seguida, construir uma pequena " "calculadora que usa ambos.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Com +" @@ -6857,13 +6857,13 @@ msgstr "Com +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Pegue 10% do primeiro número.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Com a ×" @@ -6871,13 +6871,13 @@ msgstr "Com a ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Transformar 10% em 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Do aplicativo instalado para um botão % funcional" @@ -6889,7 +6889,7 @@ msgstr "Do aplicativo instalado para um botão % funcional" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6906,7 +6906,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6918,12 +6918,12 @@ msgstr "" "regras{ph3}{ph4}Verifique o ramo com a fonte da Microsoft e, em seguida, " "tente + e elimi acima.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Veja o código real e como a resposta foi verificada" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6944,7 +6944,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6954,17 +6954,17 @@ msgstr "" "{ph0}Percentagem de execução{ph1} · {ph2}Casos de teste da Microsoft{ph3} · " "{ph4}Guia de configuração nativo{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Sua primeira investigação" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Tente REA em um pequeno aplicativo." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6973,12 +6973,12 @@ msgstr "" "Baixe nosso exemplo de notas, Rastreie sua exportação CSV e verifique uma " "entrada alterada." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Experimente o exemplo guiado" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Quer se aprofundar?" @@ -6987,7 +6987,7 @@ msgstr "Quer se aprofundar?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7007,13 +7007,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Exemplo de pedido * Sítio Web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Sim, esta." @@ -7026,7 +7026,7 @@ msgstr "Sim, esta." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7041,7 +7041,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7055,23 +7055,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Ver estudo de caso DX-Ball{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Para as etapas de configuração e análise, escolha o seu destino:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Programas de código de máquina" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7080,17 +7080,17 @@ msgstr "" "Inspecione funções, strings, referências e relações de chamada de função em " "executáveis e bibliotecas." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Análise do Guia de programas de código de máquina" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7099,17 +7099,17 @@ msgstr "" "Mapeie módulos, rotas, IPC e dependências nativas de uma pasta de aplicativo" " ou arquivo ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Fluxos de trabalho da aplicação" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Atividade do navegador e do tempo de execução" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7118,12 +7118,12 @@ msgstr "" "Capture a atividade selecionada do navegador ou do processo e compare os " "resultados entre as execuções." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Guia de observação do navegador" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Alguma dúvida?" @@ -7134,7 +7134,7 @@ msgstr "Alguma dúvida?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7144,12 +7144,12 @@ msgstr "" "{ph0}Leia as perguntas frequentes{ph1}, {ph2}fale connosco em Discord {ph3}," " ou {ph4}abrir um problema GitHub{ph5} para bugs e solicitações de recursos." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Participe da comunidade" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7173,27 +7173,27 @@ msgstr "" " seu agente de codificação. Siga guias claros e experimente exemplos " "interativos com REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Copiar o aviso de instalação do REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Comando Copiar configuração" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Siga a análise manual da calculadora" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Pergunta percentual da calculadora de cópia" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7202,32 +7202,32 @@ msgstr "" "Mini-jogo de dinossauros. Use o botão de salto ou pressione espaço enquanto " "o jogo está focado." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Copie o alerta da análise do dinossauro" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Escolha o cálculo" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Cópia Calculadora análise prompt" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Copiar pedido de Projecto do website" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Copiar o prompt de reconstrução do jogo" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Voltar ao topo" @@ -11694,26 +11694,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Mesmo espaçamento: 256 16 = 16 unidades angulares = 22,5" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Casos práticos" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11731,8 +11711,8 @@ msgstr "Casos práticos" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Casos práticos" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11751,6 +11731,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Começar" @@ -11783,7 +11783,7 @@ msgstr "Guias" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11803,7 +11803,7 @@ msgstr "Código-fonte" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11823,7 +11823,7 @@ msgstr "Licença MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11843,7 +11843,7 @@ msgstr "Participe da comunidade" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11954,37 +11954,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Veja como o REA funciona ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Guia de instalação" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Apresenta →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Definições seleccionadas a partir do script inspeccionado" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Resumo legível do manipulador %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * instruções originais selecionadas" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "IDs do operador * fonte da Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Siga @reatools em X" @@ -12394,7 +12389,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12413,7 +12408,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12642,7 +12637,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12655,7 +12650,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12668,7 +12663,7 @@ msgstr "Por exemplo: {ph0}por que a calculadora retorna 220 para {ph1}?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12679,7 +12674,7 @@ msgstr "" "{ph3} dão 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12688,7 +12683,7 @@ msgstr "REA * carregado {ph0} * linhas seleccionadas" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12706,7 +12701,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12765,6 +12760,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "principal/{ph0} * receptor da área de transferência" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Começar {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Guia de instalação" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/ru/messages.po b/website/locales/ru/messages.po index f2a0ff4336..bb6945210f 100644 --- a/website/locales/ru/messages.po +++ b/website/locales/ru/messages.po @@ -73,7 +73,7 @@ msgstr "Автор: N0zoM1z0 · 10 октября 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6032,7 +6032,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "На этой странице" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Настройка REA" @@ -6132,7 +6132,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6140,13 +6140,13 @@ msgstr "Уже знакомы с RE? {ph0}Перейти к руководств #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Хотите копнуть глубже? {ph0}Читать блог →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Вставьте этот запрос в своего ИИ-помощника для программирования:" @@ -6159,38 +6159,38 @@ msgstr "Вставьте этот запрос в своего ИИ-помощн #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ваш кодирующий агент" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Копировать" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Или выполните эту команду в терминале:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Проверьте и одобрите план изменений, затем перезапустите помощника." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Что такое обратная разработка?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6205,7 +6205,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6217,7 +6217,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6225,7 +6225,7 @@ msgstr "До REA · Вы исследуете {ph0}самостоятельно{ #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6233,7 +6233,7 @@ msgstr "{ph0}01{ph1} Декодирование ветвей" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6241,7 +6241,7 @@ msgstr "{ph0}02{ph1} Отслеживание вызовов" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6251,20 +6251,20 @@ msgstr "{ph0}03{ph1} Восстановите правило" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Оригинальные выбранные инструкции для x64 ·{ph1}{ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 · В каком филиале?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6274,29 +6274,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Два перехода в одно и то же место. Что это за " "операторы?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Найдите идентификаторы команд, затем пометьте ветвь." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Разделить ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Умножить ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Что делают вызываемые функции?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6306,24 +6306,24 @@ msgstr "" "“100 - это чисто. Вызовы - это просто адреса. {ph0}Какой из них делится? " "Какое значение входит в?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Проверьте вспомогательные функции и проследите сохраненные значения." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Делить" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 · Каково это правило?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6333,19 +6333,19 @@ msgstr "" "“Этот путь умножается на другие входные данные. Таким образом, {ph0}10% " "означает 10% от 200{ph1} после +”." -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Назовите операнды и проверьте оба пути к операторам." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Наглядное рассуждение эксперта" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6357,20 +6357,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "С REA · Вы спрашиваете {ph0}своего помощника{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Всего один запрос." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6384,21 +6384,21 @@ msgstr "Задайте помощнику вопрос {ph0}обычными с #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Пример подсказки · Калькулятор" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Пример ответа · основан на результатах исследования" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6406,29 +6406,29 @@ msgid "" msgstr "" "После + кнопка % выводит {ph0}процентное значение от первого числа.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA предоставляет" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Инструкции обработчика, декомпилированный код и вызовы." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Помощник объясняет" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Какая ветвь применяется, что она вычисляет и как ее перестроить." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6441,7 +6441,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6451,37 +6451,37 @@ msgstr "" "{ph0}Давайте рассмотрим два примера.{ph1} Измените скорость игры, затем " "вернитесь в калькулятор и перестройте его кнопку %." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Пример 01 · Игра в динозавров Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Почему динозавр ускоряется?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Цель" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Перестраивайте игру с {ph0}регулируемой скоростью.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Зачем его перепроектировать?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6492,49 +6492,49 @@ msgstr "" "игре{ph1}: как быстро оно начинается, насколько увеличивается и когда " "прекращается." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Играть в воссозданную версию" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Играть" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Прыжок ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Начать заново" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Скорость" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Используйте правило восстановленного ускорения" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Первоначальное правило: начинайте с 6, затем увеличивайте до 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Перемещайте ползунок, чтобы установить собственную скорость. Пробел или " "прыжок убирают кактус." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6543,14 +6543,14 @@ msgstr "" "Включите JavaScript для воспроизведения реконструкции. Приведенный выше код " "и пояснения остаются читаемыми." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA вернул" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6569,7 +6569,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6582,7 +6582,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6596,7 +6596,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6609,7 +6609,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6619,12 +6619,12 @@ msgstr "" "{ph0}Начните с 6.{ph1} Добавляйте 0,001 при каждом обновлении без сбоев, " "пока скорость ниже 13.{ph2}{ph3}{ph4}{ph5}" -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Откройте лабораторию скорости →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Что делает каждая часть" @@ -6635,7 +6635,7 @@ msgstr "Что делает каждая часть" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6651,7 +6651,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6668,7 +6668,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6678,13 +6678,13 @@ msgstr "" "{ph0}03 · Вы{ph1}{ph2} меняете скорость и играете{ph3}{ph4} с оригинальным " "ускорением, затем выбираете свой собственный темп.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Ознакомьтесь со сценарием, проверками и тем, как попробовать анализ" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6694,7 +6694,7 @@ msgstr "" "REA проверил версию HTTP-браузера через локальное отладочное соединение и " "вернул загруженный {ph0}, включая его исходный код и дайджест." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6708,7 +6708,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6725,7 +6725,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6743,7 +6743,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6752,19 +6752,19 @@ msgstr "" "{ph0}Проверенный код скорости{ph1} · {ph2}Авторы Chromium · Лицензия " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Пример 02 · Калькулятор Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Воссоздайте кнопку % калькулятора." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6775,7 +6775,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6786,29 +6786,29 @@ msgstr "" "проверяем установленное приложение, чтобы определить, к какому числу " "применяется процентное соотношение." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Попробуйте произвести расчет" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Сбросить" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% от 200 = 20. Тогда 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Интерактивная иллюстрация проверяемого процентного правила." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6820,7 +6820,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6834,7 +6834,7 @@ msgstr "В этом примере {ph0}, а {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6850,7 +6850,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6862,7 +6862,7 @@ msgstr "" "Windows. Восстановите правила после + и ×, затем создайте небольшой " "калькулятор, который использует оба параметра.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "С +" @@ -6870,13 +6870,13 @@ msgstr "С +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Возьмем 10% от первого числа.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "С ×" @@ -6884,13 +6884,13 @@ msgstr "С ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Превратите 10% в 0,1200{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "От установленного приложения к рабочей кнопке %" @@ -6902,7 +6902,7 @@ msgstr "От установленного приложения к рабочей #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6919,7 +6919,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6931,12 +6931,12 @@ msgstr "" "правила{ph3}{ph4}, сверяет ветвь с исходным кодом Microsoft, затем " "попробуйте + и ×, указанные выше.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Посмотрите реальный код и то, как был проверен ответ" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6957,7 +6957,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6967,17 +6967,17 @@ msgstr "" "{ph0}Процент реализации{ph1} · {ph2}Тестовые примеры Microsoft{ph3} · " "Руководство по {ph4}встроенной настройке{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Ваше первое расследование" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Попробуйте REA в небольшом приложении." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6986,12 +6986,12 @@ msgstr "" "Загрузите наш пример Notes, проследите его экспорт CSV и проверьте один " "измененный ввод." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Попробуйте руководствоваться приведенным примером" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Хотите разобраться глубже?" @@ -7000,7 +7000,7 @@ msgstr "Хотите разобраться глубже?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7020,13 +7020,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Пример запроса · Веб-сайт" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Да, вот этот." @@ -7039,7 +7039,7 @@ msgstr "Да, вот этот." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7054,7 +7054,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7068,24 +7068,24 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Смотрите тематическое исследование DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "" "Для выполнения этапов настройки и анализа выберите свой целевой объект:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Программы с машинным кодом" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7094,17 +7094,17 @@ msgstr "" "Проверяйте функции, строки, ссылки и связи между вызовами функций в " "исполняемых файлах и библиотеках." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Руководство по анализу программ с машинным кодом" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript и Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7113,17 +7113,17 @@ msgstr "" "Отображайте модули, маршруты, IPC и собственные зависимости из папки " "приложения или архива ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Рабочие процессы приложений" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Активность в браузере и во время выполнения" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7132,12 +7132,12 @@ msgstr "" "Фиксируйте активность выбранного браузера или процесса, а затем сравнивайте " "результаты в разных запусках." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Руководство по наблюдению за браузером" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Есть вопросы?" @@ -7148,7 +7148,7 @@ msgstr "Есть вопросы?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7159,12 +7159,12 @@ msgstr "" "нами по Discord{ph3} или {ph4}откройте выпуск{ph5} GitHub, чтобы узнать об " "ошибках и запросах функций." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Вступить в сообщество" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7188,27 +7188,27 @@ msgstr "" "помощью вашего coding agent. Следуйте четким инструкциям и попробуйте " "интерактивные примеры с REA." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Скопировать запрос на установку REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Команда настройки копирования" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Следуйте инструкциям ручного калькулятора для анализа" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Процентный вопрос калькулятора копирования" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7217,32 +7217,32 @@ msgstr "" "Мини-игра про динозавров. Используйте кнопку перехода или нажмите пробел, " "пока игра сосредоточена." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Запрос на копирование результатов анализа динозавров" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Выберите способ расчета" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Запрос на анализ с помощью калькулятора копирования" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Запрос на копирование проекта веб-сайта" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Запрос на копирование реконструкции игры" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Наверх" @@ -11731,26 +11731,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Одинаковое расстояние: 256 ÷ 16 = 16 угловых единиц = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Примеры" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11768,8 +11748,8 @@ msgstr "Примеры" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Блог" +msgid "Showcases" +msgstr "Примеры" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11788,6 +11768,26 @@ msgstr "Блог" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Блог" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Начать" @@ -11820,7 +11820,7 @@ msgstr "Руководства" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11840,7 +11840,7 @@ msgstr "Исходный код" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11860,7 +11860,7 @@ msgstr "Лицензия MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11880,7 +11880,7 @@ msgstr "Вступить в сообщество" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11992,37 +11992,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Посмотрите, как работает REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Руководство по установке" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Витрины →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Выбранные настройки из проверенного скрипта" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Читаемая сводка об обработчике %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA · выбранные оригинальные инструкции" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Идентификаторы операторов · Исходный код Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Подписывайтесь на @reatools на X" @@ -12432,7 +12427,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12451,7 +12446,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12684,7 +12679,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12697,7 +12692,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12710,7 +12705,7 @@ msgstr "Например: {ph0}почему калькулятор выдаёт #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12721,7 +12716,7 @@ msgstr "" "{ph3} дают 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12730,7 +12725,7 @@ msgstr "REA · загружено {ph0} · выбранные строки" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12748,7 +12743,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12807,6 +12802,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "main/{ph0} · приемник буфера обмена" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Начать {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Руководство по установке" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Оболочка PowerShell Windows" diff --git a/website/locales/th/messages.po b/website/locales/th/messages.po index a1e72df50d..29d2119734 100644 --- a/website/locales/th/messages.po +++ b/website/locales/th/messages.po @@ -73,7 +73,7 @@ msgstr "โดย N0zoM1z0 ·10 ตุลาคม 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5861,7 +5861,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "ในหน้านี้" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "ตั้งค่า REA" @@ -5961,7 +5961,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5969,13 +5969,13 @@ msgstr "รู้อยู่แล้วว่าอีกครั้ง? {ph0 #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "ต้องการที่จะไปลึก? {ph0}อ่านบล็อก→{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "วางพรอมป์ต์นี้ในผู้ช่วยเขียนโค้ด AI ของคุณ:" @@ -5988,38 +5988,38 @@ msgstr "วางพรอมป์ต์นี้ในผู้ช่วยเ #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}ตัวแทนการเข้ารหัสของคุณ" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "คัดลอก" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "หรือรันคำสั่งนี้ในเทอร์มินัล:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "ตรวจสอบและอนุมัติแผนการตั้งค่า แล้วเริ่มผู้ช่วยใหม่" -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "วิศวกรรมย้อนกลับคืออะไร?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6032,7 +6032,7 @@ msgstr "การทำความเข้าใจซอฟต์แวร์ #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6044,7 +6044,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6052,7 +6052,7 @@ msgstr "ก่อนใช้ REA · คุณต้องตรวจสอบ #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6060,7 +6060,7 @@ msgstr "{ph0}01{ph1} ถอดรหัสสาขา" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6068,7 +6068,7 @@ msgstr "{ph0}02{ph1} ติดตามการโทร" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6078,20 +6078,20 @@ msgstr "{ph0}03{ph1} กู้คืนกฎ" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}ต้นฉบับ 64·เลือกคำแนะนำ{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01·สาขาใด?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6101,29 +6101,29 @@ msgstr "" "“{ph0}0*5 บาท? 0*5 องศาเซลเซียส?{ph1} สองกระโดดไปยังสถานที่เดียวกัน " "ผู้ประกอบการคนไหน”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "ค้นหารหัสคำสั่งแล้วติดป้ายชื่อสาขา" -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "แบ่ง÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "คูณ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · ฟังก์ชันที่เรียกทำอะไร?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6133,24 +6133,24 @@ msgstr "" "\"100 เป็นที่ชัดเจน การโทรเป็นแค่ที่อยู่ {ph0}อันไหนแบ่งกัน? " "ซึ่งค่าไปใน?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "ตรวจสอบฟังก์ชั่นผู้ช่วยและติดตามค่าที่เก็บไว้" -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "แบ่ง" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03·กฎคืออะไร?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6160,19 +6160,19 @@ msgstr "" "\"เส้นทางนี้คูณด้วยอินพุตอื่นๆ แล้ว {ph0}10%หมายถึง 10%ของ 200{ph1} " "หลังจาก+.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "ตั้งชื่อตัวดำเนินการและตรวจสอบเส้นทางตัวดำเนินการทั้งสอง" -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "เหตุผลผู้เชี่ยวชาญที่เป็นตัวอย่าง" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6182,20 +6182,20 @@ msgstr "{ph0}คุณถอดรหัสคำแนะนำตามสา #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "เมื่อใช้ REA · คุณถาม {ph0}ผู้ช่วย AI{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "ใช้พรอมป์ต์เดียวก็พอ" #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6209,49 +6209,49 @@ msgstr "ถามผู้ช่วย {ph0}ด้วยภาษาทั่ว #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}ตัวอย่างพรอมต์*เครื่องคิดเลข" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "คำตอบตัวอย่าง·ขึ้นอยู่กับการค้นพบ" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "หลังจาก+ปุ่ม%ใช้เวลา {ph0}เปอร์เซ็นต์ของจำนวนแรก{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "ข้อมูลที่ REA ให้" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "คำแนะนำของผู้จัดการถอดรหัสและการโทร" -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "สิ่งที่ผู้ช่วยอธิบาย" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "ซึ่งสาขาที่ใช้สิ่งที่มันคำนวณและวิธีการที่จะสร้างมัน" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6263,7 +6263,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6273,37 +6273,37 @@ msgstr "" "{ph0}ลองสองตัวอย่าง{ph1} " "เปลี่ยนความเร็วของเกมแล้วกลับไปที่เครื่องคิดเลขและสร้างปุ่ม%ของมัน" -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "ตัวอย่าง 01*Chromeเกมไดโนเสาร์" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "ทำไมไดโนเสาร์จึงวิ่งเร็วขึ้น?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "งเขา" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "สร้างเกมด้วย {ph0}ปรับความเร็ว{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "ทำไมวิศวกรรมย้อนกลับมันได้หรือไม่" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6313,48 +6313,48 @@ msgstr "" "ในการเร่งความเร็วของมันเราต้องการ " "{ph0}กฎในเกมที่ทำงาน{ph1}:วิธีที่รวดเร็วจะเริ่มต้นวิธีการที่มันจะเพิ่มขึ้นและเมื่อมันหยุด" -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "เล่นเกมที่สร้างขึ้นใหม่" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "เล่น" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "กระโดด ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "เริ่มใหม่" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "ความเร็ว" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "ใช้กฎการเร่งการกู้คืน" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "กฎเดิม:เริ่มต้นที่ 6 จากนั้นเพิ่มขึ้นถึง 13" -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "เลื่อนแถบเลื่อนเพื่อตั้งค่าความเร็วของคุณเอง พื้นที่หรือกระโดดล้างแคคตัส" -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6362,14 +6362,14 @@ msgid "" msgstr "" "เปิดใช้งาน JavaScript เพื่อเล่นการฟื้นฟู รหัสและคำอธิบายข้างต้นยังคงอ่านได้" -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "ผลลัพธ์ที่ REA ส่งกลับ" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6387,7 +6387,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6399,7 +6399,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}ตัวอย่างพร #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6412,7 +6412,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6424,7 +6424,7 @@ msgstr "ตรวจสอบเป้าหมาย: {ph0}วายู Chromiu #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6434,12 +6434,12 @@ msgstr "" "{ph0}เริ่มที่ 6{ph1} เพิ่ม {ph2}0.001{ph3} " "แต่ละการปรับปรุงโดยไม่ต้องปะทะกันในขณะที่ความเร็วอยู่ด้านล่าง {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "เปิดห้องปฏิบัติการความเร็ว→" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "สิ่งที่แต่ละส่วนทำ" @@ -6450,7 +6450,7 @@ msgstr "สิ่งที่แต่ละส่วนทำ" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6466,7 +6466,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6481,7 +6481,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6490,13 +6490,13 @@ msgid "" msgstr "" "{ph0}03·คุณ{ph1}{ph2}เปลี่ยนความเร็วและเล่น{ph3}{ph4}ลองเร่งเดิมแล้วเลือกก้าวของคุณเอง{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "ดูสคริปต์การตรวจสอบและวิธีการลองวิเคราะห์" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6506,7 +6506,7 @@ msgstr "" "REA ตรวจสอบรุ่นเบราว์เซอร์ผ่านการเชื่อมต่อดีบักท้องถิ่นและส่งกลับโหลด {ph0} " "รวมทั้งแหล่งที่มาและย่อย" -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6519,7 +6519,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6535,7 +6535,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6552,7 +6552,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6560,19 +6560,19 @@ msgid "" msgstr "" "{ph0}ตรวจสอบรหัสความเร็ว{ph1} · {ph2}Chromium ผู้เขียน·ใบอนุญาตบีเอส{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "ตัวอย่าง 02*Windowsเครื่องคิดเลข" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "สร้างปุ่ม % ของเครื่องคิดเลขขึ้นใหม่" #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6581,7 +6581,7 @@ msgstr "สร้างเครื่องคิดเลขขนาดเล #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6591,29 +6591,29 @@ msgstr "" "ปุ่มเดียวกันใช้ {ph0}กฎที่แตกต่างกันหลังจาก+และ×{ph1} " "เราตรวจสอบการตรวจสอบที่ติดตั้งเพื่อหาจำนวนเปอร์เซ็นต์ถูกนำไปใช้" -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "ลองคำนวณ" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "รีเซ็ต" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10%ของ 200=20. จากนั้น 200+20=220" -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "ภาพประกอบโต้ตอบของกฎร้อยละตรวจสอบ" #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6625,7 +6625,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6639,7 +6639,7 @@ msgstr "สำหรับตัวอย่างนี้, {ph0} และ {ph #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6654,7 +6654,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6665,7 +6665,7 @@ msgstr "" "{ph0}›{ph1}{ph2}ใช้ REA เพื่อตรวจสอบ Windows ปุ่ม%ของเครื่องคิดเลข " "กู้คืนกฎหลังจาก+และ×แล้วสร้างเครื่องคิดเลขขนาดเล็กที่ใช้ทั้งสอง{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "ด้วย+" @@ -6673,13 +6673,13 @@ msgstr "ด้วย+" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}เอา 10%ของเลขแรก{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "กับ×" @@ -6687,13 +6687,13 @@ msgstr "กับ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}เปลี่ยน 10%เป็น 0.1{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "จากแอปที่ติดตั้งไปยังปุ่ม%การทำงาน" @@ -6705,7 +6705,7 @@ msgstr "จากแอปที่ติดตั้งไปยังปุ่ #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6723,7 +6723,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6733,12 +6733,12 @@ msgid "" msgstr "" "{ph0}03·ตัวแทนของคุณตีความมัน{ph1}{ph2}อธิบายและทำซ้ำกฎ{ph3}{ph4}ตรวจสอบสาขาที่มีแหล่งที่มาของไมโครซอฟท์,แล้วลอง+และ×ข้างต้น.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "ดูรหัสจริงและวิธีการตรวจสอบคำตอบ" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6758,7 +6758,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6768,17 +6768,17 @@ msgstr "" "{ph0}การดำเนินงานร้อยละ{ph1} · {ph2}กรณีทดสอบของไมโครซอฟท์{ph3} · " "{ph4}คู่มือการติดตั้งพื้นเมือง{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "การสืบสวนครั้งแรกของคุณ" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "ลองดู REA บนแอปขนาดเล็ก" -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6787,12 +6787,12 @@ msgstr "" "ดาวน์โหลดตัวอย่างบันทึกของเรา,ติดตามของ CSV " "ส่งออกและตรวจสอบการป้อนข้อมูลที่มีการเปลี่ยนแปลงอย่างใดอย่างหนึ่ง" -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "ลองตัวอย่างที่แนะนำ" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "อยากศึกษาให้ลึกขึ้นไหม?" @@ -6801,7 +6801,7 @@ msgstr "อยากศึกษาให้ลึกขึ้นไหม?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6821,13 +6821,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}ตัวอย่างพรอมต์*เว็บไซต์" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "ใช่อันนี้" @@ -6840,7 +6840,7 @@ msgstr "ใช่อันนี้" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6853,7 +6853,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}ตัวอย่างพร #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6866,23 +6866,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}ดู DX-Ball กรณีศึกษา→{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "สำหรับขั้นตอนการตั้งค่าและการวิเคราะห์เลือกเป้าหมายของคุณ:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "โปรแกรมเครื่องรหัส" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -6890,34 +6890,34 @@ msgid "" msgstr "" "ตรวจสอบฟังก์ชัน,สตริง,การอ้างอิงและเรียกฟังก์ชันความสัมพันธ์ในปฏิบัติการและไลบรารี." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "คู่มือการวิเคราะห์โปรแกรมเครื่องรหัส" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " "or ASAR archive." msgstr "การประมวลผลข้อมูล,คีย์ข้อมูล,การพิมพ์ดีดคัดลอก ASAR ยวข้อง" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "เวิร์กโฟลว์แอ็พพลิเคชัน" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "เบราว์เซอร์และรันไทม์กิจกรรม" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -6925,12 +6925,12 @@ msgid "" msgstr "" "จับภาพกิจกรรมของเบราว์เซอร์หรือกระบวนการที่เลือกแล้วเปรียบเทียบผลลัพธ์ข้ามรัน" -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "คู่มือการสังเกตเบราว์เซอร์" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "มีคำถามไหม?" @@ -6941,7 +6941,7 @@ msgstr "มีคำถามไหม?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6951,12 +6951,12 @@ msgstr "" "{ph0}อ่านคำถามที่พบบ่อย{ph1}, {ph2}แชทกับเราใน Discord{ph3} หรือ {ph4}เปิด " "GitHub ปัญหา{ph5} สำหรับข้อบกพร่องและการร้องขอคุณสมบัติ" -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "เข้าร่วมชุมชน" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6979,59 +6979,59 @@ msgstr "" "แอพพลิเควิศวกรย้อนกลับ,ไบนารีและพฤติกรรมเบราว์เซอร์กับตัวแทนการเข้ารหัสของคุณ" " ทำตามคำแนะนำที่ชัดเจนและลองตัวอย่างแบบโต้ตอบกับ REA." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "คัดลอก REA พร้อมท์การติดตั้ง" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "คัดลอกคำสั่งการตั้งค่า" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "ปฏิบัติตามคู่มือการวิเคราะห์เครื่องคิดเลข" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "คัดลอกคำถามร้อยละเครื่องคิดเลข" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " "focused." msgstr "ไดโนเสาร์มินิเกม ใช้ปุ่มกระโดดหรือกดพื้นที่ในขณะที่เกมจะมุ่งเน้น" -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "คัดลอกไดโนเสาร์พร้อมท์วิเคราะห์" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "เลือกการคำนวณ" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "คัดลอกการวิเคราะห์เครื่องคิดเลขพร้อมท์" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "คัดลอกเว็บไซต์พร้อมท์โครงการ" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "สำเนาพร้อมท์การฟื้นฟูเกม" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "กลับขึ้นด้านบน" @@ -11375,26 +11375,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "ระยะห่างเดียวกัน:256÷16=16 หน่วยมุม=22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "ตัวอย่าง" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11412,8 +11392,8 @@ msgstr "ตัวอย่าง" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "บล็อก" +msgid "Showcases" +msgstr "ตัวอย่าง" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11432,6 +11412,26 @@ msgstr "บล็อก" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "บล็อก" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "เริ่มต้น" @@ -11464,7 +11464,7 @@ msgstr "คู่มือ" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11484,7 +11484,7 @@ msgstr "ซอร์สโค้ด" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11504,7 +11504,7 @@ msgstr "สัญญาอนุญาต MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11524,7 +11524,7 @@ msgstr "เข้าร่วมชุมชน" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11635,37 +11635,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "ดูว่า REA ทำงานอย่างไร ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "คู่มือการติดตั้ง" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "โชว์เคส→" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "การตั้งค่าที่เลือกจากสคริปต์ที่ตรวจสอบแล้ว" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "สรุปที่สามารถอ่านได้ของตัวจัดการ%" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA *เลือกคำแนะนำเดิม" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "รหัสผู้ประกอบการ·แหล่งที่มาของไมโครซอฟท์" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "ดูบทสนทนาซ่อนบทสนทนา" @@ -12074,7 +12069,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12093,7 +12088,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12321,7 +12316,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12334,7 +12329,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12347,7 +12342,7 @@ msgstr "ตัวอย่าง: {ph0}ทำไมเครื่องคิ #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12358,7 +12353,7 @@ msgstr "" "220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12367,7 +12362,7 @@ msgstr "REA *โหลด {ph0} *สายที่เลือก" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12385,7 +12380,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12444,6 +12439,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "หลัก/{ph0} *รับคลิปบอร์ด" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "เริ่มต้น {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "คู่มือการติดตั้ง" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/tr/messages.po b/website/locales/tr/messages.po index b0488bbccb..4b4d2b5d2e 100644 --- a/website/locales/tr/messages.po +++ b/website/locales/tr/messages.po @@ -74,7 +74,7 @@ msgstr "Başlangıç N0zoM1z0 * 10 Ekim 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5968,7 +5968,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Bu sayfada" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "REA'YU ayarlama" @@ -6068,7 +6068,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6076,13 +6076,13 @@ msgstr "RE'Yİ zaten biliyor musun? {ph0}Analiz kılavuzlarına atla →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Daha derine inmek ister misin? {ph0}Blogu Oku →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Bu istemi kodlama ajanınıza yapıştırın:" @@ -6095,39 +6095,39 @@ msgstr "Bu istemi kodlama ajanınıza yapıştırın:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Kodlama aracınız" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Kopyala" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Ya da terminalde şu komutu çalıştırın:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "" "Kurulum planını inceleyip onaylayın, ardından ajanınızı yeniden başlatın." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Tersine mühendislik nedir?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6142,7 +6142,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6154,7 +6154,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6162,7 +6162,7 @@ msgstr "REA’dan önce · {ph0}Kendiniz{ph1} araştırırsınız" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6170,7 +6170,7 @@ msgstr "{ph0}01{ph1} Dalları çöz" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6178,7 +6178,7 @@ msgstr "{ph0}02{ph1} İzleme çağrıları" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6188,20 +6188,20 @@ msgstr "{ph0}03{ph1} Kuralı kurtar" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Orijinal x64 * seçilen talimatlar{ph1}{ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * Hangi şube?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6210,29 +6210,29 @@ msgid "" msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Aynı yere iki atlayış. Bunlar hangi operatörler?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Komut kimliklerine bakın, ardından dalı etiketleyin." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Böl ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Çarpın ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Çağrılan işlevler ne yapıyor?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6242,24 +6242,24 @@ msgstr "" "“100 açıktır. Aramalar sadece adres. {ph0}Hangisi bölünüyor? Hangi değer " "giriyor?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Yardımcı işlevleri inceleyin ve depolanan değerleri izleyin." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Bölmek" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * Kural nedir?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6269,19 +6269,19 @@ msgstr "" "\"Bu yol diğer girdiyle çarpılır. Yani% 10, + 'dan sonra {ph0}200'ün% 10'u " "anlamına{ph1} gelir.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "İşlenenleri adlandırın ve her iki operatör yolunu da kontrol edin." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Açıklayıcı uzman akıl yürütme" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6293,20 +6293,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "REA ile · {ph0}Ajanınıza{ph1} sorarsınız" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Tek bir istem." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6320,43 +6320,43 @@ msgstr "Ajanınıza {ph0}günlük dille{ph1} sorun." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Örnek bilgi istemi * Hesap Makinesi" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Örnek cevap · bulgulara dayanarak" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "+ 'Dan sonra, % düğmesi {ph0}ilk sayının bir yüzdesini alır.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA’nın sağladıkları" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "İşleyicinin talimatları, derlenmiş kod ve çağrılar." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Ajanınızın açıklaması" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "" @@ -6364,7 +6364,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6376,7 +6376,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6386,37 +6386,37 @@ msgstr "" "{ph0}İki örnek deneyelim.{ph1} Bir oyunun hızını değiştirin, ardından Hesap " "Makinesine dönün ve% düğmesini yeniden oluşturun." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Örnek 01 * Chrome'nun dinozor oyunu" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Dinozor neden hızlanıyor?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Hedef" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Oyunu {ph0}ayarlanabilir hızda yeniden oluşturun.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Neden tersine mühendislik?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6426,49 +6426,49 @@ msgstr "" "İvmesini yeniden üretmek için {ph0}koşu oyununda kurala{ph1} ihtiyacımız " "var: ne kadar hızlı başladığı, ne kadar arttığı ve ne zaman durduğu." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Yeniden oluşturulan oyunu oyna" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Oyna" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Zıpla ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Yeniden başlat" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Hız" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Kurtarılan hızlanma kuralını kullanın" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Orijinal kural: 6'dan başlayın, ardından 13'e doğru artırın." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Kendi hızınızı ayarlamak için kaydırıcıyı hareket ettirin. Boşluk veya " "Atlama bir kaktüsü temizler." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6477,14 +6477,14 @@ msgstr "" "Yeniden yapılandırmayı oynatmak için JavaScript'YU etkinleştirin. Yukarıdaki" " kod ve açıklama okunabilir durumda kalır." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA’nın döndürdüğü sonuç" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6502,7 +6502,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6514,7 +6514,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Örnek istemi * Dinozor oyunu" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6528,7 +6528,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6542,7 +6542,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6552,12 +6552,12 @@ msgstr "" "{ph0}6'dan başla.{ph1} Hız {ph2}13{ph3}'ün altındayken çarpışma olmadan her " "güncellemeye {ph4}0,001{ph5} ekleyin." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Hız laboratuvarını açın" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Her bölüm ne yapar" @@ -6568,7 +6568,7 @@ msgstr "Her bölüm ne yapar" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6584,7 +6584,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6601,7 +6601,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6611,13 +6611,13 @@ msgstr "" "{ph0}03 *{ph1}{ph2} Hızı değiştirir ve oynarsınız{ph3}{ph4} Orijinal ivmeyi " "deneyin, ardından kendi hızınızı seçin.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Komut dosyasına, kontrollere ve analizin nasıl deneneceğine bakın" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6627,7 +6627,7 @@ msgstr "" "REA, HTTP tarayıcı sürümünü yerel bir hata ayıklama bağlantısı aracılığıyla " "denetledi ve yüklenen {ph0}, kaynağı ve özeti dahil." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6641,7 +6641,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6657,7 +6657,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6675,7 +6675,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6683,19 +6683,19 @@ msgid "" msgstr "" "{ph0}Denetlenen hız kodu{ph1} * {ph2}Chromium Yazarları · BSD lisansı{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Örnek 02 * Windows Hesap Makinesi" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Hesap Makinesi’nin % düğmesini yeniden oluşturun." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6706,7 +6706,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6716,29 +6716,29 @@ msgstr "" "Aynı düğme {ph0}+ ve × 'den sonra farklı kurallar kullanır.{ph1} Yüzdenin " "hangi sayıya uygulandığını bulmak için yüklü uygulamayı inceliyoruz." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Hesaplamayı deneyin" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Sıfırla" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "200'ün% 10'u = 20. Sonra 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Denetlenen yüzde kuralının etkileşimli gösterimi." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6750,7 +6750,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6764,7 +6764,7 @@ msgstr "Bu örnek için {ph0} ve {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6780,7 +6780,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6792,7 +6792,7 @@ msgstr "" "kullanın. Kuralları + ve × 'den sonra kurtarın, ardından her ikisini de " "kullanan küçük bir hesap makinesi oluşturun.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "İle +" @@ -6800,13 +6800,13 @@ msgstr "İle +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}İlk sayının% 10'unu alın.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "İle ×" @@ -6814,13 +6814,13 @@ msgstr "İle ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}% 10'u 0.1.200 × 0.1 = 20'ye çevirin{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Yüklü uygulamadan çalışan bir% düğmesine" @@ -6832,7 +6832,7 @@ msgstr "Yüklü uygulamadan çalışan bir% düğmesine" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6850,7 +6850,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6862,12 +6862,12 @@ msgstr "" "çoğaltın{ph3}{ph4} Dalı Microsoft'un kaynağıyla çapraz kontrol edin, " "ardından + ve × üstünü deneyin.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Gerçek kodu ve cevabın nasıl kontrol edildiğini görün" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6888,7 +6888,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6898,17 +6898,17 @@ msgstr "" "{ph0}Yüzde uygulaması{ph1} * {ph2}Microsoft'un test durumları{ph3} * " "{ph4}Yerel kurulum kılavuzu{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "İlk soruşturmanız" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Küçük bir uygulamada REA'yu deneyin." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6917,12 +6917,12 @@ msgstr "" "Notlar örneğimizi indirin, CSV dışa aktarımını izleyin ve değiştirilen bir " "girişi kontrol edin." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Kılavuzlu örneği deneyin" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Daha derine inmek ister misiniz?" @@ -6931,7 +6931,7 @@ msgstr "Daha derine inmek ister misiniz?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6952,14 +6952,14 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Örnek bilgi istemi * Web sitesi" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Evet, bu." @@ -6972,7 +6972,7 @@ msgstr "Evet, bu." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6986,7 +6986,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7000,23 +7000,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}DX-Ball örnek olay incelemesine bakın →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Kurulum ve analiz adımları için hedefinizi seçin:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Makine kodu programları" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7025,17 +7025,17 @@ msgstr "" "Yürütülebilir dosyalar ve kitaplıklardaki işlevleri, dizeleri, referansları " "ve işlev çağrısı ilişkilerini inceleyin." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Makine kodu programlarının analizi kılavuzu" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript VE Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7044,17 +7044,17 @@ msgstr "" "Modülleri, rotaları, ıpc'yi ve yerel bağımlılıkları bir uygulama klasöründen" " veya ASAR arşivinden eşleyin." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Uygulama iş akışları" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Tarayıcı ve çalışma zamanı etkinliği" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7063,12 +7063,12 @@ msgstr "" "Seçilen tarayıcıyı veya işlem etkinliğini yakalayın, ardından sonuçları " "koşular arasında karşılaştırın." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Tarayıcı gözlem kılavuzu" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Sorunuz var mı?" @@ -7079,7 +7079,7 @@ msgstr "Sorunuz var mı?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7089,12 +7089,12 @@ msgstr "" "{ph0}Sss'yi okuyun{ph1}, Discord'DA {ph2}bizimle sohbet{ph3} edin veya " "hatalar ve özellik istekleri için {ph4}bir GitHub sorunu açın{ph5}." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Topluluğa katıl" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7118,27 +7118,27 @@ msgstr "" "tersine mühendislik yapın. Net kılavuzları izleyin ve REA ile etkileşimli " "örnekleri deneyin." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "REA kurulum istemini kopyalayın" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Kurulum komutunu kopyala" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Manuel Hesap Makinesi analizini takip edin" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Hesap Makinesi yüzdesi sorusunu kopyala" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7147,32 +7147,32 @@ msgstr "" "Dinozor mini oyunu. Oyun odaklanırken Atlama düğmesini kullanın veya Boşluk " "tuşuna basın." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Dinozor analizi istemini kopyala" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Hesaplamayı seçin" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Hesap makinesi analiz istemini kopyala" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Web sitesi proje istemini kopyala" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Oyun yeniden yapılandırma istemini kopyala" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Başa dön" @@ -11604,26 +11604,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Aynı aralık: 256 ÷ 16 = 16 açı birimi = 22,5 °" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Örnekler" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11641,8 +11621,8 @@ msgstr "Örnekler" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Örnekler" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11661,6 +11641,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Başlangıç" @@ -11693,7 +11693,7 @@ msgstr "Rehberler" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11713,7 +11713,7 @@ msgstr "Kaynak kod" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11733,7 +11733,7 @@ msgstr "MIT lisansı" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11753,7 +11753,7 @@ msgstr "Topluluğa katıl" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11864,37 +11864,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "REA’nın nasıl çalıştığını görün ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Kurulum rehberi" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Vitrinler →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Denetlenen komut dosyasından seçilen ayarlar" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "% işleyicisinin okunabilir özeti" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * seçilen orijinal talimatlar" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Operatör Kimlikleri * Microsoft'un kaynağı" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "X'te @reatools'u takip edin" @@ -12304,7 +12299,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12323,7 +12318,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12549,7 +12544,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12562,7 +12557,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12576,7 +12571,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12587,7 +12582,7 @@ msgstr "" " {ph3}220 veriyor?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12596,7 +12591,7 @@ msgstr "REA * yüklendi {ph0}* seçili satırlar" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12614,7 +12609,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12673,6 +12668,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "ana /{ph0} * pano alıcısı" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Başlangıç {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Kurulum rehberi" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows Powershell'in" diff --git a/website/locales/uk/messages.po b/website/locales/uk/messages.po index 871aeb0f99..3debfedc12 100644 --- a/website/locales/uk/messages.po +++ b/website/locales/uk/messages.po @@ -73,7 +73,7 @@ msgstr "Автор: N0zoM1z0 · 10 жовтня 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -6001,7 +6001,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "На цій сторінці" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Налаштування REA" @@ -6101,7 +6101,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6109,13 +6109,13 @@ msgstr "Вже знайомі з RE? {ph0}Перейти до посібникі #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Хочете копнути глибше? {ph0}Читати блог →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Вставте цей запит у свого ШІ-помічника для програмування:" @@ -6128,38 +6128,38 @@ msgstr "Вставте цей запит у свого ШІ-помічника #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ваш кодуючий агент" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Копіювати" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Або виконайте цю команду в терміналі:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Перевірте й схваліть план змін, а потім перезапустіть помічника." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Що таке зворотна розробка?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6174,7 +6174,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6186,7 +6186,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6194,7 +6194,7 @@ msgstr "До REA · Ви досліджуєте {ph0}самостійно{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6202,7 +6202,7 @@ msgstr "{ph0}01{ph1} Декодування гілок" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6210,7 +6210,7 @@ msgstr "{ph0}02{ph1} Відстеження викликів" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6220,20 +6220,20 @@ msgstr "{ph0}03{ph1} Відновіть правило" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Оригінальні вибрані інструкції для x64 ·{ph1}{ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * в якій філії?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6243,29 +6243,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Два переходи в одне і те ж місце. Що це за " "оператори?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Знайдіть ідентифікатори команд, а потім позначте гілку." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Розділити ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Помноживши ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Що роблять викликані функції?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6275,24 +6275,24 @@ msgstr "" "\"100-це чисто. Дзвінки-це лише адреси. {ph0}Який з них ділиться? Яке " "значення входить в?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Перевірте допоміжні функції та простежте збережені значення." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Діливши" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * яке це правило?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6302,19 +6302,19 @@ msgstr "" "\"Цей шлях множиться на інші вхідні дані. Таким чином, {ph0}10% означає 10% " "від 200{ph1} після +”." -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Назвіть операнди та перевірте обидва шляхи до операторів." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Наочне міркування експерта" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6326,20 +6326,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "З REA · Ви запитуєте {ph0}свого помічника{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Лише один запит." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6353,21 +6353,21 @@ msgstr "Запитайте помічника {ph0}звичайними слов #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "" "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Приклад підказки * Калькулятор" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Приклад відповіді * заснований на результатах дослідження" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6375,29 +6375,29 @@ msgid "" msgstr "" "Після + кнопка % виводить {ph0}процентне значення від першого числа.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA надає" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Інструкції обробника, декомпільований код та виклики." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Помічник пояснює" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Яка гілка застосовується, що вона обчислює і як її перебудувати." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6410,7 +6410,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6420,37 +6420,37 @@ msgstr "" "{ph0}Давайте розглянемо два приклади.{ph1} Змініть швидкість гри, потім " "поверніться до калькулятора та відновіть його кнопку %." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Приклад 01 * гра в динозаврів Chrome" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Чому динозавр прискорюється?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Мета" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Перебудовуйте гру з {ph0}регульованою швидкістю.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Навіщо його перепроектувати?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6461,49 +6461,49 @@ msgstr "" "грі{ph1}: як швидко воно починається, наскільки збільшується і коли " "припиняється." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Грати у відтворену версію" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Грати" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Стрибок ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Почати знову" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Швидкість" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Використовуйте правило відновленого прискорення" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Початкове правило: починайте з 6, потім збільшуйте до 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Перемістіть повзунок, щоб встановити власну швидкість. Пробіл або стрибок " "прибирають кактус." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6512,14 +6512,14 @@ msgstr "" "Увімкніть JavaScript для відтворення реконструкції. Наведений вище код та " "пояснення залишаються читабельними." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA повернув" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6538,7 +6538,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6551,7 +6551,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6565,7 +6565,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6578,7 +6578,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6588,12 +6588,12 @@ msgstr "" "{ph0}Почніть з 6.{ph1} Додавайте 0,001 при кожному оновленні без збоїв, поки" " швидкість нижче 13.{ph2}{ph3}{ph4}{ph5}" -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Відкрийте лабораторію швидкості →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Що робить кожна частина" @@ -6604,7 +6604,7 @@ msgstr "Що робить кожна частина" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6620,7 +6620,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6637,7 +6637,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6647,13 +6647,13 @@ msgstr "" "{ph0}03 * ви{ph1}{ph2} міняєте швидкість і граєте{ph3}{ph4} з оригінальним " "прискоренням, потім вибираєте свій власний темп.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Ознайомтеся зі сценарієм, перевірками та як спробувати аналіз" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6663,7 +6663,7 @@ msgstr "" "REA перевірив версію браузера HTTP через локальне з'єднання Налагодження та " "повернув завантажений {ph0}, включаючи його вихідний код та дайджест." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6677,7 +6677,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6694,7 +6694,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6712,7 +6712,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6721,19 +6721,19 @@ msgstr "" "{ph0}Перевірений код швидкості{ph1} · {ph2}Автори Chromium * Ліцензія " "BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Приклад 02 * Калькулятор Windows" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Відтворіть кнопку % калькулятора." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6744,7 +6744,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6755,29 +6755,29 @@ msgstr "" "перевіряємо встановлене додаток, щоб визначити, до якого числа " "застосовується процентне співвідношення." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Спробуйте зробити розрахунок" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Скинути" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% від 200 = 20. Тоді 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Інтерактивна ілюстрація перевіряється процентного правила." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6789,7 +6789,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6803,7 +6803,7 @@ msgstr "У цьому прикладі {ph0}, а {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6819,7 +6819,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6831,7 +6831,7 @@ msgstr "" "Windows. Відновіть правила після + і×, а потім створіть невеликий " "калькулятор, який використовує обидва параметри.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Со +" @@ -6839,13 +6839,13 @@ msgstr "Со +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Візьмемо 10% від першого числа.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Со ×" @@ -6853,13 +6853,13 @@ msgstr "Со ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Перетворіть 10% на 0,1200{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Від встановленого додатка до робочої кнопки %" @@ -6871,7 +6871,7 @@ msgstr "Від встановленого додатка до робочої к #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6888,7 +6888,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6900,12 +6900,12 @@ msgstr "" "правила{ph3}{ph4}, звіряє гілку з вихідним кодом Microsoft, а потім " "спробуйте + і × вище.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Подивіться реальний код і те, як була перевірена відповідь" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6926,7 +6926,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6936,17 +6936,17 @@ msgstr "" "{ph0}Відсоток реалізації{ph1} · {ph2}Тестові приклади Microsoft{ph3} * " "Керівництво по {ph4}вбудованої налаштуванні{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Ваше перше розслідування" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Спробуйте REA у невеликому додатку." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6955,12 +6955,12 @@ msgstr "" "Завантажте наш приклад Notes, простежте його експорт CSV і перевірте один " "змінений вхід." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Спробуйте керуватися наведеним прикладом" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Хочете дізнатися більше?" @@ -6969,7 +6969,7 @@ msgstr "Хочете дізнатися більше?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6989,13 +6989,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Приклад запиту * Веб-сайт" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Так, ось цей." @@ -7008,7 +7008,7 @@ msgstr "Так, ось цей." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7022,7 +7022,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7036,24 +7036,24 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Дивіться тематичне дослідження DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "" "Для виконання етапів настройки і аналізу виберіть свій цільовий об'єкт:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Програми з машинним кодом" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7062,17 +7062,17 @@ msgstr "" "Перевірте функції, рядки, посилання та зв'язки між викликами функцій у " "виконуваних файлах та бібліотеках." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Керівництво по аналізу програм з машинним кодом" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript і Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7081,17 +7081,17 @@ msgstr "" "Відображайте модулі, маршрути, IPC та власні залежності з папки програми або" " архіву ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Робочі процеси додатків" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Активність у браузері та під час виконання" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7100,12 +7100,12 @@ msgstr "" "Фіксуйте активність обраного браузера або процесу, а потім порівнюйте " "результати в різних запусках." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Керівництво по спостереженню за браузером" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Маєте запитання?" @@ -7116,7 +7116,7 @@ msgstr "Маєте запитання?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7127,12 +7127,12 @@ msgstr "" " Discord{ph3} або {ph4}відкрийте випуск{ph5} GitHub, щоб дізнатися про " "помилки та запити функцій." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Приєднатися до спільноти" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7156,27 +7156,27 @@ msgstr "" "агента кодування. Дотримуйтесь чітких інструкцій та спробуйте інтерактивні " "приклади з REA." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Скопіюйте запит на встановлення REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Команда Налаштування Копіювання" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Дотримуйтесь інструкцій ручного калькулятора для аналізу" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Процентне питання калькулятора копіювання" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7185,32 +7185,32 @@ msgstr "" "Міні-гра про динозаврів. Використовуйте кнопку переходу або натисніть " "пробіл, поки гра зосереджена." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Запит на копіювання результатів аналізу динозаврів" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Виберіть спосіб розрахунку" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Запит на аналіз за допомогою калькулятора копіювання" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Запит на копіювання проекту веб-сайту" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Запит на копіювання реконструкції гри" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Нагору" @@ -11683,26 +11683,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Однакова відстань: 256 ÷ 16 = 16 кутових одиниць = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Приклади" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11720,8 +11700,8 @@ msgstr "Приклади" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Блог" +msgid "Showcases" +msgstr "Приклади" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11740,6 +11720,26 @@ msgstr "Блог" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Блог" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Почати" @@ -11772,7 +11772,7 @@ msgstr "Посібники" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11792,7 +11792,7 @@ msgstr "Вихідний код" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11812,7 +11812,7 @@ msgstr "Ліцензія MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11832,7 +11832,7 @@ msgstr "Приєднатися до спільноти" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11944,37 +11944,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Подивіться, як працює REA ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Посібник зі встановлення" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Вітрина →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Вибрані налаштування з перевіреного скрипта" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Читається зведення про обробнику %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * вибрані оригінальні інструкції" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Ідентифікатори операторів * вихідний код Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Слідкуйте за @reatools на X" @@ -12384,7 +12379,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12403,7 +12398,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12635,7 +12630,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12648,7 +12643,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12661,7 +12656,7 @@ msgstr "Наприклад: {ph0}чому калькулятор дає 220 дл #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12672,7 +12667,7 @@ msgstr "" "{ph3} дають 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12681,7 +12676,7 @@ msgstr "REA · завантажено {ph0} * вибрані рядки" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12699,7 +12694,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12758,6 +12753,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "головний/{ph0} * приймач буфера обміну" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Почати {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Посібник зі встановлення" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Оболонка PowerShell Windows" diff --git a/website/locales/vi/messages.po b/website/locales/vi/messages.po index ea5901ffd8..313aca2855 100644 --- a/website/locales/vi/messages.po +++ b/website/locales/vi/messages.po @@ -73,7 +73,7 @@ msgstr "Bởi N0zoM1z0 * 10 tháng mười 2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5975,7 +5975,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "Trên trang này" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "Thiết LẬP REA" @@ -6075,7 +6075,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -6083,13 +6083,13 @@ msgstr "ĐÃ BIẾT LẠI? {ph0}Chuyển đến hướng dẫn phân tích →{p #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "Bạn muốn đi sâu hơn? {ph0}Đọc Blog →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "Dán lời nhắc này vào trợ lý lập trình AI của bạn:" @@ -6102,38 +6102,38 @@ msgstr "Dán lời nhắc này vào trợ lý lập trình AI của bạn:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Agent mã hóa của bạn" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "Sao chép" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "Hoặc chạy lệnh này trong terminal:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "Xem và chấp thuận kế hoạch cài đặt, rồi khởi động lại trợ lý." -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "Kỹ thuật đảo ngược là gì?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6148,7 +6148,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6160,7 +6160,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -6168,7 +6168,7 @@ msgstr "Trước khi dùng REA · Bạn {ph0}tự điều tra{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -6176,7 +6176,7 @@ msgstr "{ph0}01{ph1} Giải mã chi nhánh" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -6184,7 +6184,7 @@ msgstr "{ph0}02{ph1} Theo dõi cuộc gọi" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -6194,20 +6194,20 @@ msgstr "{ph0}03{ph1} Khôi phục quy tắc" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}Ban đầu x64 * lựa chọn hướng dẫn{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 * chi Nhánh Nào?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6217,29 +6217,29 @@ msgstr "" "“{ph0}0x5b? 0x5c?{ph1} Hai bước nhảy đến cùng một nơi. Những nhà khai thác " "này là gì?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "Tra Cứu id lệnh, sau đó gắn nhãn nhánh." -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "Chia ÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "Nhân ×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · Các hàm được gọi làm gì?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6249,24 +6249,24 @@ msgstr "" "\"100 là rõ ràng. Các cuộc gọi chỉ là địa chỉ. {ph0}Cái nào phân chia? Giá " "trị nào đi vào?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "Kiểm tra các chức năng trợ giúp và theo dõi các giá trị được lưu trữ." -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "Chia" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 * quy tắc Là gì?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6276,19 +6276,19 @@ msgstr "" "\"Con đường này nhân với đầu vào khác. Vậy {ph0}10% có nghĩa là 10% của " "200{ph1} sau +.”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "Đặt tên cho các toán hạng và kiểm tra cả hai đường dẫn toán tử." -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "Lý luận chuyên gia minh họa" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6300,20 +6300,20 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "Với REA · Bạn hỏi {ph0}trợ lý AI{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "Chỉ một lời nhắc." #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -6327,49 +6327,49 @@ msgstr "Hỏi trợ lý {ph0}bằng ngôn ngữ thông thường.{ph1}" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ví dụ nhắc · Máy tính" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "Câu trả lời ví dụ · dựa trên những phát hiện" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "Sau +, nút % mất {ph0}một tỷ lệ phần trăm của số đầu tiên.{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA cung cấp" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "Hướng dẫn của trình xử lý, mã dịch ngược và các cuộc gọi." -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "Trợ lý giải thích" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "Chi nhánh nào áp dụng, những gì nó tính toán và cách xây dựng lại nó." #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6381,7 +6381,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6391,37 +6391,37 @@ msgstr "" "{ph0}Hãy thử hai ví dụ.{ph1} Thay đổi tốc độ của trò chơi, sau đó quay lại " "Máy tính và xây dựng lại nút % của trò chơi." -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "Ví dụ 01 * Chrome ' s trò chơi khủng long" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "Vì sao khủng long chạy nhanh dần?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "Mục tiêu" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "Xây dựng lại trò chơi với {ph0}tốc độ điều chỉnh.{ph1}" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "Tại sao kỹ sư đảo ngược nó?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6432,49 +6432,49 @@ msgstr "" "chạy{ph1}: nó bắt đầu nhanh như thế nào, nó tăng bao nhiêu và khi nào nó " "dừng lại." -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "Chơi bản dựng lại" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "Chơi" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "Nhảy ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "Chơi lại" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "Tốc độ" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "Sử dụng quy tắc tăng tốc đã khôi phục" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "Quy tắc ban đầu: bắt đầu từ 6, sau đó tăng lên 13." -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "" "Di chuyển thanh trượt để thiết lập tốc độ của riêng bạn. Không gian Hoặc " "Nhảy xóa một cây xương rồng." -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" @@ -6483,14 +6483,14 @@ msgstr "" "Cho PHÉP JavaScript để chơi tái thiết. Mã và giải thích ở trên vẫn có thể " "đọc được." -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA trả về" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6508,7 +6508,7 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -6521,7 +6521,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6535,7 +6535,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -6549,7 +6549,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6559,12 +6559,12 @@ msgstr "" "{ph0}Bắt đầu từ 6.{ph1} Thêm {ph2}0.001{ph3} mỗi bản cập nhật mà không có va" " chạm, trong khi tốc độ thấp hơn {ph4}13{ph5}." -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "Mở phòng thí nghiệm tốc độ →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "Mỗi phần làm gì" @@ -6575,7 +6575,7 @@ msgstr "Mỗi phần làm gì" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6591,7 +6591,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6608,7 +6608,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6618,13 +6618,13 @@ msgstr "" "{ph0}03 * Bạn{ph1}{ph2}Thay đổi tốc độ và chơi{ph3}{ph4}Hãy thử tăng tốc ban" " đầu, sau đó chọn tốc độ của riêng bạn.{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "Xem kịch bản, kiểm tra và cách thử phân tích" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6634,7 +6634,7 @@ msgstr "" "REA đã kiểm tra PHIÊN bản TRÌNH duyệt HTTP thông qua kết nối gỡ lỗi cục bộ " "và trả về đã tải {ph0}, bao gồm cả nguồn và tiêu hóa của nó." -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -6648,7 +6648,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6664,7 +6664,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6682,7 +6682,7 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6690,19 +6690,19 @@ msgid "" msgstr "" "{ph0}Kiểm tra mã tốc độ{ph1} · {ph2} Tác GIẢ Chromium * giấy phép BSD{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "Ví dụ 02 * Windows Máy Tính" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "Dựng lại nút % của Máy tính." #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6711,7 +6711,7 @@ msgstr "Xây dựng một máy tính nhỏ xử lý {ph0}cả + và × chính x #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6721,29 +6721,29 @@ msgstr "" "Nút tương tự sử dụng {ph0}các quy tắc khác nhau sau + và ×.{ph1} Chúng tôi " "kiểm tra ứng dụng đã cài đặt để tìm số phần trăm được áp dụng." -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "Hãy thử tính toán" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "Đặt lại" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "10% của 200 = 20. Sau đó, 200 + 20 = 220." -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "Minh họa tương tác của quy tắc phần trăm được kiểm tra." #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6755,7 +6755,7 @@ msgstr "" #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6769,7 +6769,7 @@ msgstr "Đối với ví dụ này, {ph0} và {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6784,7 +6784,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6796,7 +6796,7 @@ msgstr "" "phục các quy tắc sau + và ×, sau đó xây dựng một máy tính nhỏ sử dụng cả " "hai.{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "Với +" @@ -6804,13 +6804,13 @@ msgstr "Với +" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}Lấy 10% số đầu tiên.{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "Với ×" @@ -6818,13 +6818,13 @@ msgstr "Với ×" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}Biến 10% thành 0,1.{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "Từ ứng dụng đã cài đặt đến nút % hoạt động" @@ -6836,7 +6836,7 @@ msgstr "Từ ứng dụng đã cài đặt đến nút % hoạt động" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6853,7 +6853,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6865,12 +6865,12 @@ msgstr "" "quy tắc{ph3}{ph4}Kiểm tra chéo nhánh với Nguồn Của Microsoft, sau đó thử + " "và × ở trên.{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "Xem mã thực và câu trả lời đã được kiểm tra như thế nào" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6891,7 +6891,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6901,17 +6901,17 @@ msgstr "" "{ph0}Thực hiện tỷ lệ phần trăm{ph1} · {ph2}Trường hợp thử nghiệm của " "Microsoft{ph3} · {ph4}Hướng dẫn thiết lập gốc{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "Cuộc điều tra đầu tiên của bạn" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "Hãy thử REA trên một ứng dụng nhỏ." -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " @@ -6920,12 +6920,12 @@ msgstr "" "Tải Xuống Ví Dụ Ghi Chú của chúng tôi, theo dõi XUẤT CSV của NÓ và kiểm tra " "một đầu vào đã thay đổi." -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "Hãy thử ví dụ hướng dẫn" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "Muốn tìm hiểu sâu hơn?" @@ -6934,7 +6934,7 @@ msgstr "Muốn tìm hiểu sâu hơn?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6954,13 +6954,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Ví dụ nhắc * Trang Web" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "Vâng, cái này." @@ -6973,7 +6973,7 @@ msgstr "Vâng, cái này." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6987,7 +6987,7 @@ msgstr "" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7001,23 +7001,23 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}Xem nghiên cứu điển hình DX-Ball →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "Đối với các bước thiết lập và phân tích, hãy chọn mục tiêu của bạn:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "Chương trình mã máy" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" @@ -7026,17 +7026,17 @@ msgstr "" "Kiểm tra các hàm, chuỗi, tham chiếu và mối quan hệ gọi hàm trong tệp thực " "thi và thư viện." -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "Phân tích hướng dẫn chương trình mã máy" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript & Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " @@ -7045,17 +7045,17 @@ msgstr "" "Các mô-đun bản đồ, tuyến ĐƯỜNG, IPC và các phụ thuộc gốc từ thư mục ứng dụng" " hoặc KHO LƯU trữ ASAR." -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "Quy trình làm việc ứng dụng" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "Hoạt động trình duyệt và thời gian chạy" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " @@ -7064,12 +7064,12 @@ msgstr "" "Nắm bắt các trình duyệt được lựa chọn hoặc quá trình hoạt động, sau đó so " "sánh các kết quả trên chạy." -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "Hướng dẫn quan sát trình duyệt" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "Bạn có câu hỏi?" @@ -7080,7 +7080,7 @@ msgstr "Bạn có câu hỏi?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -7090,12 +7090,12 @@ msgstr "" "{ph0}Đọc CÂU hỏi THƯỜNG gặp{ph1}, {ph2}trò chuyện VỚI CHÚNG TÔI TRÊN Discord" " {ph3}, hoặc {ph4}mở sự cố GitHub{ph5} đối với lỗi và yêu cầu tính năng." -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "Tham gia cộng đồng" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -7118,27 +7118,27 @@ msgstr "" "Ứng dụng kỹ sư đảo ngược, nhị phân và hành vi trình duyệt với tác nhân mã " "hóa của bạn. Làm theo hướng dẫn rõ ràng và thử các ví dụ tương tác VỚI REA ." -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "Sao CHÉP lời nhắc cài ĐẶT REA" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "Sao chép lệnh thiết lập" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "Thực hiện theo phân tích máy tính thủ công" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "Sao chép câu hỏi tỷ lệ phần Trăm máy Tính" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " @@ -7147,32 +7147,32 @@ msgstr "" "Khủng long mini-game. Sử dụng Nút Nhảy hoặc Nhấn Không gian trong khi trò " "chơi được tập trung." -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "Sao chép dấu nhắc phân tích khủng long" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "Chọn tính toán" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "Sao chép lời nhắc phân tích máy tính" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "Sao chép lời nhắc dự án trang web" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "Sao chép lời nhắc tái thiết trò chơi" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "Về đầu trang" @@ -11593,26 +11593,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "Khoảng cách tương tự: 256 ÷ 16 = 16 đơn vị góc = 22,5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "Ví dụ thực tế" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -11630,8 +11610,8 @@ msgstr "Ví dụ thực tế" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "Blog" +msgid "Showcases" +msgstr "Ví dụ thực tế" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -11650,6 +11630,26 @@ msgstr "Blog" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "Blog" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "Bắt đầu" @@ -11682,7 +11682,7 @@ msgstr "Hướng dẫn" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -11702,7 +11702,7 @@ msgstr "Mã nguồn" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -11722,7 +11722,7 @@ msgstr "Giấy phép MIT" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -11742,7 +11742,7 @@ msgstr "Tham gia cộng đồng" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -11853,37 +11853,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "Xem REA hoạt động thế nào ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "Hướng dẫn cài đặt" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "Giới thiệu →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "Cài đặt đã chọn từ tập lệnh được kiểm tra" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "Tóm tắt có thể đọc được của trình xử lý %" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA * hướng dẫn ban đầu được chọn" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "Id nhà điều Hành * Nguồn Của Microsoft" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "Theo dõi @ reatools Trên X" @@ -12293,7 +12288,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12312,7 +12307,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12538,7 +12533,7 @@ msgstr "" #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12551,7 +12546,7 @@ msgstr "" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -12564,7 +12559,7 @@ msgstr "Ví dụ: {ph0}vì sao Máy tính cho kết quả 220 khi tính {ph1}?{p #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12575,7 +12570,7 @@ msgstr "" "220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -12584,7 +12579,7 @@ msgstr "REA · đã tải {ph0} * các dòng đã chọn" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12602,7 +12597,7 @@ msgstr "" #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -12660,6 +12655,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "chính/{ph0} * bộ thu clipboard" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "Bắt đầu {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "Hướng dẫn cài đặt" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "Windows PowerShell" diff --git a/website/locales/zh-CN/messages.po b/website/locales/zh-CN/messages.po index 3d9d4c4e9a..58908a5eb9 100644 --- a/website/locales/zh-CN/messages.po +++ b/website/locales/zh-CN/messages.po @@ -71,7 +71,7 @@ msgstr "通过N0zoM1z0*10十月2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5327,7 +5327,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "在本页" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "安装与配置 REA" @@ -5425,7 +5425,7 @@ msgstr "REA 为编程助手提供分析工具,让它检查程序并解释程 #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5433,13 +5433,13 @@ msgstr "已经了解逆向工程?{ph0}直接看分析指南 →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "想深入了解?{ph0}阅读博客 →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "把这段话复制给编程助手:" @@ -5452,38 +5452,38 @@ msgstr "把这段话复制给编程助手:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}你的编程助手" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "复制" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "也可以在终端运行:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "确认安装计划后,重启编程助手。" -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "什么是逆向工程?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5496,7 +5496,7 @@ msgstr "通过{ph0}检查程序本身{ph1},弄清软件如何工作。" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5506,7 +5506,7 @@ msgstr "目标是理解一项功能,从而能{ph0}解释它{ph1}、{ph2}修改 #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -5514,7 +5514,7 @@ msgstr "使用 REA 之前 · 你要{ph0}亲自调查{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -5522,7 +5522,7 @@ msgstr "{ph0}01{ph1} 分析分支" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -5530,7 +5530,7 @@ msgstr "{ph0}02{ph1} 追踪函数调用" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -5540,20 +5540,20 @@ msgstr "{ph0}03{ph1} 还原计算规则" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}原始 x64 指令节选{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 · 走哪个分支?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5561,29 +5561,29 @@ msgid "" "these?”" msgstr "“{ph0}0x5b?0x5c?{ph1}两个跳转去了同一个位置。它们对应哪些运算符?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "查找命令 ID,再标明分支含义。" -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "除÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "乘×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · 这些函数调用做什么?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5591,24 +5591,24 @@ msgid "" "value goes in?{ph1}”" msgstr "“100 很明确,但函数调用只有地址。{ph0}哪个负责除法?传进去的是哪个值?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "检查辅助函数,追踪存储的数值。" -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "除法" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 · 规则是什么?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5616,19 +5616,19 @@ msgid "" "after +.”" msgstr "“这个分支乘上了另一个输入。所以在 + 之后,{ph0}10% 表示 200 的 10%{ph1}。”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "确定操作数的含义,并检查两条运算路径。" -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "专家思考过程示意" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5638,20 +5638,20 @@ msgstr "{ph0}你需要读懂指令、追踪调用,再还原计算过程。{ph1 #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "使用 REA · 你只需问{ph0}编程助手{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "一句话即可。" #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -5665,49 +5665,49 @@ msgstr "{ph0}用日常语言{ph1}告诉助手你的问题。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 计算器" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "示例回答 · 根据分析结果" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "在 + 之后,% 按钮计算的是{ph0}第一个数的百分比。{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA 提供" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "处理函数的指令、反编译代码和调用关系。" -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "编程助手解释" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "走哪个分支、怎样计算,以及如何重新实现。" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5717,7 +5717,7 @@ msgstr "汇编节选自已安装的计算器 DLL。思考过程和回答用于 #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5725,37 +5725,37 @@ msgid "" "Calculator and rebuild its % button." msgstr "{ph0}来试两个例子。{ph1}先调整游戏速度,再回到计算器,重新实现 % 按钮。" -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "示例 01 · Chrome 小恐龙游戏" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "为什么恐龙变得更快?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "目标" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "重建一个{ph0}速度可调{ph1}的游戏。" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "为什么逆向工程?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5763,61 +5763,61 @@ msgid "" "game{ph1}: how fast it starts, how much it increases and when it stops." msgstr "要复现加速过程,得先找到{ph0}实际运行的游戏中的规则{ph1}:起始速度是多少、每次增加多少、何时停止加速。" -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "试玩重建版" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "开始" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "跳跃 ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "重新开始" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "速度" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "使用还原的加速规则" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "原版规则:速度从 6 开始,逐渐增加到 13。" -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "拖动滑块,选择自己的速度。按空格键或“跳跃”越过仙人掌。" -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" " remain readable." msgstr "启用 JavaScript 即可试玩。上面的代码和说明无需 JavaScript 也能阅读。" -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA 返回" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5833,7 +5833,7 @@ msgstr "{ph0}页面实际运行的脚本。{ph1}助手读取更新函数和速 #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -5845,7 +5845,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 小恐龙游戏 #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5858,7 +5858,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -5870,7 +5870,7 @@ msgstr "分析目标:{ph0}wayou 基于 Chromium 制作的浏览器版本{ph1} #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5878,12 +5878,12 @@ msgid "" "while speed is below {ph4}13{ph5}." msgstr "{ph0}起始速度为 6。{ph1}速度低于 {ph4}13{ph5} 时,每次没有发生碰撞的更新增加 {ph2}0.001{ph3}。" -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "打开速度实验室 →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "各部分负责什么" @@ -5894,7 +5894,7 @@ msgstr "各部分负责什么" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5908,7 +5908,7 @@ msgstr "{ph0}01 · REA{ph1}{ph2}读取正在运行的游戏脚本{ph3}{ph4}把 #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5922,7 +5922,7 @@ msgstr "{ph0}02 · 编程助手{ph1}{ph2}重建规则,加入控制{ph3}{ph4} #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5930,13 +5930,13 @@ msgid "" "acceleration, then choose your own pace.{ph5}" msgstr "{ph0}03 · 你{ph1}{ph2}调整速度,亲自试玩{ph3}{ph4}先体验原版加速,再选择自己的节奏。{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "查看脚本、验证过程和分析步骤" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5944,7 +5944,7 @@ msgid "" "and returned the loaded {ph0}, including its source and digest." msgstr "REA 通过本地调试连接检查 HTTP 浏览器版本,返回了已加载的 {ph0},包含源码和摘要。" -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -5956,7 +5956,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5969,7 +5969,7 @@ msgstr "新小游戏保留了这条速度规则。绘图、跳跃和碰撞代码 #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5984,26 +5984,26 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "{ph0}分析的速度代码{ph1} · {ph2}Chromium Authors · BSD 许可证{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "示例 02 · Windows 计算器" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "重新实现计算器的 % 按钮。" #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6012,7 +6012,7 @@ msgstr "做一个小计算器,能{ph0}正确处理 + 和 × 两种情况。{ph #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6020,29 +6020,29 @@ msgid "" " installed app to find which number the percentage is applied to." msgstr "同一个按钮,在{ph0}+ 和 × 后采用不同规则。{ph1}我们检查已安装的应用,弄清百分比究竟作用于哪个数。" -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "试试计算" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "重置" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "200的10%=20. 则200+20=220。" -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "根据实际分析得到的百分比规则制作的交互示例。" #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6052,7 +6052,7 @@ msgstr "{ph0}一个分支除以 100;另一个还要乘上第一个数。{ph1} #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6066,7 +6066,7 @@ msgstr "对于这个例子, {ph0} 和 {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6080,7 +6080,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 重建 % 按钮 #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6091,7 +6091,7 @@ msgstr "" "{ph0}›{ph1}{ph2}用 REA 检查 Windows 计算器的 % 按钮。还原 + 和 × " "后的规则,再做一个同时采用这两条规则的小计算器。{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "在 + 之后" @@ -6099,13 +6099,13 @@ msgstr "在 + 之后" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}取第一个数字的10%。{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "在 × 之后" @@ -6113,13 +6113,13 @@ msgstr "在 × 之后" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}把10%变成0.1。{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "从已安装的应用,到能运行的 % 按钮" @@ -6131,7 +6131,7 @@ msgstr "从已安装的应用,到能运行的 % 按钮" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6147,7 +6147,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6158,12 +6158,12 @@ msgstr "" "{ph0}03 · 编程助手解释{ph1}{ph2}解释并复现规则{ph3}{ph4}对照 Microsoft 的源码核实分支,再试试上面的 + 和 " "×。{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "查看实际代码和验证方式" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6183,7 +6183,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6191,29 +6191,29 @@ msgid "" "{ph4}Native setup guide{ph5}" msgstr "{ph0}百分比实现{ph1} · {ph2}Microsoft 测试用例{ph3} · {ph4}原生分析配置指南{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "第一次调查" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "用一个小应用试试 REA。" -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " "input." msgstr "下载 Notes 示例,追踪 CSV 导出,再修改一个输入来验证。" -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "跟着示例开始" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "想更深入吗?" @@ -6222,7 +6222,7 @@ msgstr "想更深入吗?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6240,13 +6240,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 网站" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "对,就是这个网站。" @@ -6259,7 +6259,7 @@ msgstr "对,就是这个网站。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6272,7 +6272,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 游戏重建" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6284,69 +6284,69 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}查看 DX-Ball 案例 →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "根据目标选择配置和分析指南:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "原生二进制" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" " and libraries." msgstr "检查可执行文件和库中的函数、字符串、引用和函数调用关系。" -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "原生分析指南" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript&Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " "or ASAR archive." msgstr "从应用目录或 ASAR 文件中分析模块、路由、IPC 和原生依赖。" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "应用分析流程" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "浏览器与运行时活动" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " "across runs." msgstr "记录指定的浏览器或进程活动,再比较不同运行的结果。" -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "浏览器观察指南" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "有什么问题吗?" @@ -6357,7 +6357,7 @@ msgstr "有什么问题吗?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6367,12 +6367,12 @@ msgstr "" "{ph0}阅读 FAQ{ph1}、{ph2}在 Discord 和我们聊聊{ph3},或{ph4}提交 GitHub issue{ph5}反馈 bug " "和功能需求。" -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "加入社区" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6391,59 +6391,59 @@ msgid "" " Follow clear guides and try interactive examples with REA." msgstr "和编程助手一起分析应用、二进制文件和浏览器行为。跟着清晰的指南,体验 REA 的交互示例。" -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "复制REA安装提示" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "复制安装命令" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "按照手动计算器分析" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "复制计算器百分比问题" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " "focused." msgstr "小恐龙游戏。点击“跳跃”,或在游戏获得焦点后按空格键。" -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "复制恐龙分析提示" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "选择计算" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "复制计算器分析提示" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "复制网站项目提示" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "复制游戏重建提示" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "回到顶部" @@ -10614,26 +10614,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "相同间距:256÷16=16角度单位=22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "案例展示" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -10651,8 +10631,8 @@ msgstr "案例展示" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "博客" +msgid "Showcases" +msgstr "案例展示" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -10671,6 +10651,26 @@ msgstr "博客" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "博客" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "开始使用" @@ -10703,7 +10703,7 @@ msgstr "指南" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -10723,7 +10723,7 @@ msgstr "源代码" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -10743,7 +10743,7 @@ msgstr "MIT 许可证" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -10763,7 +10763,7 @@ msgstr "加入社区" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -10874,37 +10874,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "看看 REA 如何工作 ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "安装指南" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "查看案例 →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "实际脚本中的设置节选" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "% 处理函数的可读摘要" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA · 原始指令节选" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "运算符 ID · Microsoft 源码" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "在X上关注@reatools" @@ -11308,7 +11303,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11326,7 +11321,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11535,7 +11530,7 @@ msgstr "下载示例并解压缩。 存档包含完整的 {ph0} 文件夹。 分 #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11546,7 +11541,7 @@ msgstr "第一次使用,还没安装 {ph0} 或编程助手?{ph1}跟着安装 #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -11559,7 +11554,7 @@ msgstr "比如:{ph0}为什么计算器算 {ph1} 会得到 220?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11569,7 +11564,7 @@ msgstr "" "{ph0}›{ph1}{ph2}用 REA 检查 Windows 计算器。为什么 {ph3} 会得到 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -11578,7 +11573,7 @@ msgstr "REA · 已加载 {ph0} · 代码节选" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11594,7 +11589,7 @@ msgstr "我们已看到{ph0}为什么 {ph1} 得到 220。{ph2}现在把 + 和 × #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11651,6 +11646,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "主要/{ph0} · 剪贴板接收器" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "开始使用 {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "安装指南" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "WindowsQ" diff --git a/website/locales/zh-TW/messages.po b/website/locales/zh-TW/messages.po index 5efd36b956..fe632d0450 100644 --- a/website/locales/zh-TW/messages.po +++ b/website/locales/zh-TW/messages.po @@ -71,7 +71,7 @@ msgstr "透過N0zoM1z0*10十月2026" #: website/public/guides/browser/index.html:494 #: website/public/guides/index.html:278 #: website/public/guides/javascript/index.html:499 -#: website/public/guides/native/index.html:505 website/public/index.html:1182 +#: website/public/guides/native/index.html:505 website/public/index.html:1187 #: website/public/showcase/aegis/index.html:804 #: website/public/showcase/ctf/index.html:773 #: website/public/showcase/dx-ball/index.html:781 @@ -5343,7 +5343,7 @@ msgctxt "page:index.html" msgid "On this page" msgstr "在本頁" -#: website/public/index.html:193 website/public/index.html:242 +#: website/public/index.html:193 website/public/index.html:247 msgctxt "page:index.html" msgid "Set up REA" msgstr "安裝與配置 REA" @@ -5441,7 +5441,7 @@ msgstr "REA 為程式設計助手提供分析工具,讓它檢查程式並解 #. {ph0}: #. {ph1}: -#: website/public/index.html:232 +#: website/public/index.html:237 #, python-brace-format msgctxt "page:index.html" msgid "Already know RE? {ph0}Skip to analysis guides →{ph1}" @@ -5449,13 +5449,13 @@ msgstr "已經瞭解逆向工程?{ph0}直接看分析指南 →{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:236 +#: website/public/index.html:241 #, python-brace-format msgctxt "page:index.html" msgid "Want to go deeper? {ph0}Read the Blog →{ph1}" msgstr "想深入瞭解?{ph0}閱讀部落格 →{ph1}" -#: website/public/index.html:243 +#: website/public/index.html:248 msgctxt "page:index.html" msgid "Copy this into your coding agent:" msgstr "把這段話複製給程式設計助手:" @@ -5468,38 +5468,38 @@ msgstr "把這段話複製給程式設計助手:" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:246 +#: website/public/index.html:251 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Your coding agent" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}你的程式設計助手" -#: website/public/index.html:251 website/public/index.html:273 -#: website/public/index.html:488 website/public/index.html:656 -#: website/public/index.html:892 website/public/index.html:1054 -#: website/public/index.html:1082 +#: website/public/index.html:256 website/public/index.html:278 +#: website/public/index.html:493 website/public/index.html:661 +#: website/public/index.html:897 website/public/index.html:1059 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy" msgstr "複製" -#: website/public/index.html:270 +#: website/public/index.html:275 msgctxt "page:index.html" msgid "Or run this in your terminal:" msgstr "也可以在終端執行:" -#: website/public/index.html:282 +#: website/public/index.html:287 msgctxt "page:index.html" msgid "Approve the setup plan, then restart your agent." msgstr "確認安裝計劃後,重啟程式設計助手。" -#: website/public/index.html:297 +#: website/public/index.html:302 msgctxt "page:index.html" msgid "What is reverse engineering?" msgstr "什麼是逆向工程?" #. {ph0}: #. {ph1}: -#: website/public/index.html:298 +#: website/public/index.html:303 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5512,7 +5512,7 @@ msgstr "透過{ph0}檢查程式本身{ph1},弄清軟體如何工作。" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:303 +#: website/public/index.html:308 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5522,7 +5522,7 @@ msgstr "目標是理解一項功能,從而能{ph0}解釋它{ph1}、{ph2}修改 #. {ph0}: #. {ph1}: -#: website/public/index.html:321 +#: website/public/index.html:326 #, python-brace-format msgctxt "page:index.html" msgid "Before REA · You investigate it {ph0}yourself{ph1}" @@ -5530,7 +5530,7 @@ msgstr "使用 REA 之前 · 你要{ph0}親自調查{ph1}" #. {ph0}: #. {ph1}: -#: website/public/index.html:330 +#: website/public/index.html:335 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}01{ph1} Decode branches" @@ -5538,7 +5538,7 @@ msgstr "{ph0}01{ph1} 分析分支" #. {ph0}: #. {ph1}: -#: website/public/index.html:338 +#: website/public/index.html:343 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}02{ph1} Trace calls" @@ -5546,7 +5546,7 @@ msgstr "{ph0}02{ph1} 追蹤函式呼叫" #. {ph0}: #. {ph1}: -#: website/public/index.html:346 +#: website/public/index.html:351 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}03{ph1} Recover the rule" @@ -5556,20 +5556,20 @@ msgstr "{ph0}03{ph1} 還原計算規則" #. {ph1}: #. {ph2}: -#: website/public/index.html:361 +#: website/public/index.html:366 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Original x64 · selected instructions{ph1} {ph2}{ph3}" msgstr "{ph0}原始 x64 指令節選{ph1} {ph2}{ph3}" -#: website/public/index.html:400 +#: website/public/index.html:405 msgctxt "page:index.html" msgid "01 · Which branch?" msgstr "01 · 走哪個分支?" #. {ph0}: #. {ph1}: -#: website/public/index.html:401 +#: website/public/index.html:406 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5577,29 +5577,29 @@ msgid "" "these?”" msgstr "“{ph0}0x5b?0x5c?{ph1}兩個跳轉去了同一個位置。它們對應哪些運算子?”" -#: website/public/index.html:405 +#: website/public/index.html:410 msgctxt "page:index.html" msgid "Look up the command IDs, then label the branch." msgstr "查詢命令 ID,再標明分支含義。" -#: website/public/index.html:411 +#: website/public/index.html:416 msgctxt "page:index.html" msgid "Divide ÷" msgstr "除÷" -#: website/public/index.html:415 +#: website/public/index.html:420 msgctxt "page:index.html" msgid "Multiply ×" msgstr "乘×" -#: website/public/index.html:420 +#: website/public/index.html:425 msgctxt "page:index.html" msgid "02 · What do the calls do?" msgstr "02 · 這些函式呼叫做什麼?" #. {ph0}: #. {ph1}: -#: website/public/index.html:421 +#: website/public/index.html:426 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5607,24 +5607,24 @@ msgid "" "value goes in?{ph1}”" msgstr "“100 很明確,但函式呼叫只有地址。{ph0}哪個負責除法?傳進去的是哪個值?{ph1}”" -#: website/public/index.html:426 +#: website/public/index.html:431 msgctxt "page:index.html" msgid "Inspect the helpers and trace the stored values." msgstr "檢查輔助函式,追蹤儲存的數值。" -#: website/public/index.html:436 +#: website/public/index.html:441 msgctxt "page:index.html" msgid "Divide" msgstr "除法" -#: website/public/index.html:441 +#: website/public/index.html:446 msgctxt "page:index.html" msgid "03 · What is the rule?" msgstr "03 · 規則是什麼?" #. {ph0}: #. {ph1}: -#: website/public/index.html:442 +#: website/public/index.html:447 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5632,19 +5632,19 @@ msgid "" "after +.”" msgstr "“這個分支乘上了另一個輸入。所以在 + 之後,{ph0}10% 表示 200 的 10%{ph1}。”" -#: website/public/index.html:446 +#: website/public/index.html:451 msgctxt "page:index.html" msgid "Name the operands and check both operator paths." msgstr "確定運算元的含義,並檢查兩條運算路徑。" -#: website/public/index.html:462 website/public/index.html:390 +#: website/public/index.html:467 website/public/index.html:395 msgctxt "page:index.html" msgid "Illustrative expert reasoning" msgstr "專家思考過程示意" #. {ph0}: #. {ph1}: -#: website/public/index.html:465 +#: website/public/index.html:470 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5654,20 +5654,20 @@ msgstr "{ph0}你需要讀懂指令、追蹤呼叫,再還原計算過程。{ph1 #. {ph0}: #. {ph1}: -#: website/public/index.html:473 +#: website/public/index.html:478 #, python-brace-format msgctxt "page:index.html" msgid "With REA · You ask {ph0}your agent{ph1}" msgstr "使用 REA · 你只需問{ph0}程式設計助手{ph1}" -#: website/public/index.html:477 +#: website/public/index.html:482 msgctxt "page:index.html" msgid "One prompt." msgstr "一句話即可。" #. {ph0}: #. {ph1}: -#: website/public/index.html:478 +#: website/public/index.html:483 #, python-brace-format msgctxt "page:index.html" msgid "Ask your agent {ph0}in plain English.{ph1}" @@ -5681,49 +5681,49 @@ msgstr "{ph0}用日常語言{ph1}告訴助手你的問題。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:483 +#: website/public/index.html:488 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Calculator" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 計算器" -#: website/public/index.html:505 +#: website/public/index.html:510 msgctxt "page:index.html" msgid "Example answer · based on the findings" msgstr "示例回答 · 根據分析結果" #. {ph0}: #. {ph1}: -#: website/public/index.html:508 +#: website/public/index.html:513 #, python-brace-format msgctxt "page:index.html" msgid "" "After +, the % button takes {ph0}a percentage of the first number.{ph1}" msgstr "在 + 之後,% 按鈕計算的是{ph0}第一個數的百分比。{ph1}" -#: website/public/index.html:520 +#: website/public/index.html:525 msgctxt "page:index.html" msgid "REA supplies" msgstr "REA 提供" -#: website/public/index.html:521 +#: website/public/index.html:526 msgctxt "page:index.html" msgid "The handler’s instructions, decompiled code and calls." msgstr "處理函式的指令、反編譯程式碼和呼叫關係。" -#: website/public/index.html:526 +#: website/public/index.html:531 msgctxt "page:index.html" msgid "Your agent explains" msgstr "程式設計助手解釋" -#: website/public/index.html:527 +#: website/public/index.html:532 msgctxt "page:index.html" msgid "Which branch applies, what it calculates and how to rebuild it." msgstr "走哪個分支、怎樣計算,以及如何重新實現。" #. {ph0}: #. {ph1}: -#: website/public/index.html:534 +#: website/public/index.html:539 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5733,7 +5733,7 @@ msgstr "彙編節選自已安裝的計算器 DLL。思考過程和回答用於 #. {ph0}: #. {ph1}: -#: website/public/index.html:545 +#: website/public/index.html:550 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5741,37 +5741,37 @@ msgid "" "Calculator and rebuild its % button." msgstr "{ph0}來試兩個例子。{ph1}先調整遊戲速度,再回到計算器,重新實現 % 按鈕。" -#: website/public/index.html:551 +#: website/public/index.html:556 msgctxt "page:index.html" msgid "Example 01 · Chrome’s dinosaur game" msgstr "示例 01 · Chrome 小恐龍游戲" -#: website/public/index.html:552 +#: website/public/index.html:557 msgctxt "page:index.html" msgid "Why does the dinosaur get faster?" msgstr "為什麼恐龍變得更快?" -#: website/public/index.html:556 website/public/index.html:794 +#: website/public/index.html:561 website/public/index.html:799 msgctxt "page:index.html" msgid "Goal" msgstr "目標" #. {ph0}: #. {ph1}: -#: website/public/index.html:557 +#: website/public/index.html:562 #, python-brace-format msgctxt "page:index.html" msgid "Rebuild the game with {ph0}adjustable speed.{ph1}" msgstr "重建一個{ph0}速度可調{ph1}的遊戲。" -#: website/public/index.html:563 website/public/index.html:801 +#: website/public/index.html:568 website/public/index.html:806 msgctxt "page:index.html" msgid "Why reverse engineer it?" msgstr "為什麼逆向工程?" #. {ph0}: #. {ph1}: -#: website/public/index.html:564 +#: website/public/index.html:569 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5779,61 +5779,61 @@ msgid "" "game{ph1}: how fast it starts, how much it increases and when it stops." msgstr "要復現加速過程,得先找到{ph0}實際執行的遊戲中的規則{ph1}:起始速度是多少、每次增加多少、何時停止加速。" -#: website/public/index.html:573 +#: website/public/index.html:578 msgctxt "page:index.html" msgid "Play the reconstruction" msgstr "試玩重建版" -#: website/public/index.html:605 +#: website/public/index.html:610 msgctxt "page:index.html" msgid "Play" msgstr "開始" -#: website/public/index.html:608 +#: website/public/index.html:613 msgctxt "page:index.html" msgid "Jump ↑" msgstr "跳躍 ↑" -#: website/public/index.html:609 +#: website/public/index.html:614 msgctxt "page:index.html" msgid "Restart" msgstr "重新開始" -#: website/public/index.html:612 +#: website/public/index.html:617 msgctxt "page:index.html" msgid "Speed" msgstr "速度" -#: website/public/index.html:622 +#: website/public/index.html:627 msgctxt "page:index.html" msgid "Use the recovered acceleration rule" msgstr "使用還原的加速規則" -#: website/public/index.html:626 +#: website/public/index.html:631 msgctxt "page:index.html" msgid "Original rule: start at 6, then increase toward 13." msgstr "原版規則:速度從 6 開始,逐漸增加到 13。" -#: website/public/index.html:629 +#: website/public/index.html:634 msgctxt "page:index.html" msgid "Move the slider to set your own speed. Space or Jump clears a cactus." msgstr "拖動滑塊,選擇自己的速度。按空格鍵或“跳躍”越過仙人掌。" -#: website/public/index.html:634 +#: website/public/index.html:639 msgctxt "page:index.html" msgid "" "Enable JavaScript to play the reconstruction. The code and explanation above" " remain readable." msgstr "啟用 JavaScript 即可試玩。上面的程式碼和說明無需 JavaScript 也能閱讀。" -#: website/public/index.html:642 website/public/index.html:872 +#: website/public/index.html:647 website/public/index.html:877 msgctxt "page:index.html" msgid "REA returned" msgstr "REA 返回" #. {ph0}: #. {ph1}: -#: website/public/index.html:643 +#: website/public/index.html:648 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5849,7 +5849,7 @@ msgstr "{ph0}頁面實際執行的指令碼。{ph1}助手讀取更新函式和 #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:651 +#: website/public/index.html:656 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Dinosaur game" @@ -5861,7 +5861,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 小恐龍游戲 #. {ph3}: #. {ph4}: -#: website/public/index.html:665 +#: website/public/index.html:670 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5874,7 +5874,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:674 +#: website/public/index.html:679 #, python-brace-format msgctxt "page:index.html" msgid "Inspected target: {ph0}wayou’s Chromium-derived browser edition{ph1}." @@ -5886,7 +5886,7 @@ msgstr "分析目標:{ph0}wayou 基於 Chromium 製作的瀏覽器版本{ph1} #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:691 +#: website/public/index.html:696 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5894,12 +5894,12 @@ msgid "" "while speed is below {ph4}13{ph5}." msgstr "{ph0}起始速度為 6。{ph1}速度低於 {ph4}13{ph5} 時,每次沒有發生碰撞的更新增加 {ph2}0.001{ph3}。" -#: website/public/index.html:696 +#: website/public/index.html:701 msgctxt "page:index.html" msgid "Open the speed lab →" msgstr "開啟速度實驗室 →" -#: website/public/index.html:702 +#: website/public/index.html:707 msgctxt "page:index.html" msgid "What each part does" msgstr "各部分負責什麼" @@ -5910,7 +5910,7 @@ msgstr "各部分負責什麼" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:704 +#: website/public/index.html:709 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5924,7 +5924,7 @@ msgstr "{ph0}01 · REA{ph1}{ph2}讀取正在執行的遊戲指令碼{ph3}{ph4} #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:711 +#: website/public/index.html:716 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5939,7 +5939,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:719 +#: website/public/index.html:724 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5947,13 +5947,13 @@ msgid "" "acceleration, then choose your own pace.{ph5}" msgstr "{ph0}03 · 你{ph1}{ph2}調整速度,親自試玩{ph3}{ph4}先體驗原版加速,再選擇自己的節奏。{ph5}" -#: website/public/index.html:730 +#: website/public/index.html:735 msgctxt "page:index.html" msgid "See the script, checks and how to try the analysis" msgstr "檢視指令碼、驗證過程和分析步驟" #. {ph0}: index.js -#: website/public/index.html:733 +#: website/public/index.html:738 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5961,7 +5961,7 @@ msgid "" "and returned the loaded {ph0}, including its source and digest." msgstr "REA 透過本地除錯連線檢查 HTTP 瀏覽器版本,返回了已載入的 {ph0},包含原始碼和摘要。" -#: website/public/index.html:746 +#: website/public/index.html:751 msgctxt "page:index.html" msgid "" "We called the original game’s update function in a controlled browser check," @@ -5973,7 +5973,7 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:752 +#: website/public/index.html:757 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -5986,7 +5986,7 @@ msgstr "新小遊戲保留了這條速度規則。繪圖、跳躍和碰撞程式 #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:758 +#: website/public/index.html:763 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6001,26 +6001,26 @@ msgstr "" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:767 +#: website/public/index.html:772 #, python-brace-format msgctxt "page:index.html" msgid "" "{ph0}Inspected speed code{ph1} · {ph2}Chromium Authors · BSD license{ph3}" msgstr "{ph0}分析的速度程式碼{ph1} · {ph2}Chromium Authors · BSD 許可證{ph3}" -#: website/public/index.html:784 +#: website/public/index.html:789 msgctxt "page:index.html" msgid "Example 02 · Windows Calculator" msgstr "示例 02 · Windows 計算器" -#: website/public/index.html:785 +#: website/public/index.html:790 msgctxt "page:index.html" msgid "Rebuild Calculator’s % button." msgstr "重新實現計算器的 % 按鈕。" #. {ph0}: #. {ph1}: -#: website/public/index.html:795 +#: website/public/index.html:800 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6029,7 +6029,7 @@ msgstr "做一個小計算器,能{ph0}正確處理 + 和 × 兩種情況。{ph #. {ph0}: #. {ph1}: -#: website/public/index.html:802 +#: website/public/index.html:807 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6037,29 +6037,29 @@ msgid "" " installed app to find which number the percentage is applied to." msgstr "同一個按鈕,在{ph0}+ 和 × 後採用不同規則。{ph1}我們檢查已安裝的應用,弄清百分比究竟作用於哪個數。" -#: website/public/index.html:811 +#: website/public/index.html:816 msgctxt "page:index.html" msgid "Try the calculation" msgstr "試試計算" -#: website/public/index.html:855 +#: website/public/index.html:860 msgctxt "page:index.html" msgid "Reset" msgstr "重置" -#: website/public/index.html:863 +#: website/public/index.html:868 msgctxt "page:index.html" msgid "10% of 200 = 20. Then 200 + 20 = 220." msgstr "200的10%=20. 則200+20=220。" -#: website/public/index.html:866 +#: website/public/index.html:871 msgctxt "page:index.html" msgid "Interactive illustration of the inspected percentage rule." msgstr "根據實際分析得到的百分比規則製作的互動示例。" #. {ph0}: #. {ph1}: -#: website/public/index.html:873 +#: website/public/index.html:878 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6069,7 +6069,7 @@ msgstr "{ph0}一個分支除以 100;另一個還要乘上第一個數。{ph1} #. {ph0}: previous = 200 #. {ph1}: current = 10 -#: website/public/index.html:881 +#: website/public/index.html:886 #, python-brace-format msgctxt "page:index.html" msgid "For this example, {ph0} and {ph1}." @@ -6083,7 +6083,7 @@ msgstr "對於這個例子, {ph0} 和 {ph1}." #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:887 +#: website/public/index.html:892 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6097,7 +6097,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 重建 % 按鈕 #. {ph3}: #. {ph4}: -#: website/public/index.html:901 +#: website/public/index.html:906 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6108,7 +6108,7 @@ msgstr "" "{ph0}›{ph1}{ph2}用 REA 檢查 Windows 計算器的 % 按鈕。還原 + 和 × " "後的規則,再做一個同時採用這兩條規則的小計算器。{ph3}{ph4}{ph5}" -#: website/public/index.html:922 +#: website/public/index.html:927 msgctxt "page:index.html" msgid "With +" msgstr "在 + 之後" @@ -6116,13 +6116,13 @@ msgstr "在 + 之後" #. {ph0}: #. {ph1}: #. {ph2}: 10% of 200 = 20 → 200 + 20 = 220 -#: website/public/index.html:923 +#: website/public/index.html:928 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Take 10% of the first number.{ph1}{ph2}" msgstr "{ph0}取第一個數字的10%。{ph1}{ph2}" -#: website/public/index.html:929 +#: website/public/index.html:934 msgctxt "page:index.html" msgid "With ×" msgstr "在 × 之後" @@ -6130,13 +6130,13 @@ msgstr "在 × 之後" #. {ph0}: #. {ph1}: #. {ph2}: 200 × 0.1 = 20 -#: website/public/index.html:930 +#: website/public/index.html:935 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}Turn 10% into 0.1.{ph1}{ph2}" msgstr "{ph0}把10%變成0.1。{ph1}{ph2}" -#: website/public/index.html:939 +#: website/public/index.html:944 msgctxt "page:index.html" msgid "From the installed app to a working % button" msgstr "從已安裝的應用,到能執行的 % 按鈕" @@ -6148,7 +6148,7 @@ msgstr "從已安裝的應用,到能執行的 % 按鈕" #. {ph4}: #. {ph5}: 100 #. {ph6}: -#: website/public/index.html:952 +#: website/public/index.html:957 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6164,7 +6164,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:960 +#: website/public/index.html:965 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6175,12 +6175,12 @@ msgstr "" "{ph0}03 · 程式設計助手解釋{ph1}{ph2}解釋並復現規則{ph3}{ph4}對照 Microsoft 的原始碼核實分支,再試試上面的 + " "和 ×。{ph5}" -#: website/public/index.html:971 +#: website/public/index.html:976 msgctxt "page:index.html" msgid "See the real code and how the answer was checked" msgstr "檢視實際程式碼和驗證方式" -#: website/public/index.html:997 +#: website/public/index.html:1002 msgctxt "page:index.html" msgid "" "Inspected with REA 4.1.0: Windows Calculator 11.2508.4.0, x64. The readable " @@ -6200,7 +6200,7 @@ msgstr "" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1002 +#: website/public/index.html:1007 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6208,29 +6208,29 @@ msgid "" "{ph4}Native setup guide{ph5}" msgstr "{ph0}百分比實現{ph1} · {ph2}Microsoft 測試用例{ph3} · {ph4}原生分析配置指南{ph5}" -#: website/public/index.html:1021 +#: website/public/index.html:1026 msgctxt "page:index.html" msgid "Your first investigation" msgstr "第一次調查" -#: website/public/index.html:1022 +#: website/public/index.html:1027 msgctxt "page:index.html" msgid "Try REA on a small app." msgstr "用一個小應用試試 REA。" -#: website/public/index.html:1023 +#: website/public/index.html:1028 msgctxt "page:index.html" msgid "" "Download our Notes example, trace its CSV export, and check one changed " "input." msgstr "下載 Notes 示例,追蹤 CSV 匯出,再修改一個輸入來驗證。" -#: website/public/index.html:1028 +#: website/public/index.html:1033 msgctxt "page:index.html" msgid "Try the guided example" msgstr "跟著示例開始" -#: website/public/index.html:1037 +#: website/public/index.html:1042 msgctxt "page:index.html" msgid "Want to go deeper?" msgstr "想更深入嗎?" @@ -6239,7 +6239,7 @@ msgstr "想更深入嗎?" #. {ph1}: #. {ph2}: #. {ph3}: -#: website/public/index.html:1038 +#: website/public/index.html:1043 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6257,13 +6257,13 @@ msgstr "" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1049 +#: website/public/index.html:1054 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}Example prompt · Website" msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 網站" -#: website/public/index.html:1072 +#: website/public/index.html:1077 msgctxt "page:index.html" msgid "Yes, this one." msgstr "對,就是這個網站。" @@ -6276,7 +6276,7 @@ msgstr "對,就是這個網站。" #. {ph5}: #. {ph6}: #. {ph7}: -#: website/public/index.html:1077 +#: website/public/index.html:1082 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6289,7 +6289,7 @@ msgstr "{ph0}{ph1}{ph2}{ph3}{ph4}{ph5}{ph6}{ph7}示例 prompt · 遊戲重建" #. {ph3}: #. {ph4}: -#: website/public/index.html:1091 +#: website/public/index.html:1096 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6301,69 +6301,69 @@ msgstr "" #. {ph0}: #. {ph1}: -#: website/public/index.html:1100 +#: website/public/index.html:1105 #, python-brace-format msgctxt "page:index.html" msgid "{ph0}See the DX-Ball case study →{ph1}" msgstr "{ph0}檢視 DX-Ball 案例 →{ph1}" -#: website/public/index.html:1105 +#: website/public/index.html:1110 msgctxt "page:index.html" msgid "For setup and analysis steps, choose your target:" msgstr "根據目標選擇配置和分析指南:" -#: website/public/index.html:1110 +#: website/public/index.html:1115 msgctxt "page:index.html" msgid "Native binaries" msgstr "原生二進位制" -#: website/public/index.html:1111 +#: website/public/index.html:1116 msgctxt "page:index.html" msgid "" "Inspect functions, strings, references and call relationships in executables" " and libraries." msgstr "檢查可執行檔案和庫中的函式、字串、引用和函式呼叫關係。" -#: website/public/index.html:1115 +#: website/public/index.html:1120 msgctxt "page:index.html" msgid "Native analysis guide" msgstr "原生分析指南" -#: website/public/index.html:1118 +#: website/public/index.html:1123 msgctxt "page:index.html" msgid "JavaScript & Electron" msgstr "JavaScript&Electron" -#: website/public/index.html:1119 +#: website/public/index.html:1124 msgctxt "page:index.html" msgid "" "Map modules, routes, IPC and native dependencies from an application folder " "or ASAR archive." msgstr "從應用目錄或 ASAR 檔案中分析模組、路由、IPC 和原生依賴。" -#: website/public/index.html:1123 +#: website/public/index.html:1128 msgctxt "page:index.html" msgid "Application workflows" msgstr "應用分析流程" -#: website/public/index.html:1126 +#: website/public/index.html:1131 msgctxt "page:index.html" msgid "Browser & runtime activity" msgstr "瀏覽器與執行時活動" -#: website/public/index.html:1127 +#: website/public/index.html:1132 msgctxt "page:index.html" msgid "" "Capture selected browser or process activity, then compare the results " "across runs." msgstr "記錄指定的瀏覽器或程序活動,再比較不同執行的結果。" -#: website/public/index.html:1131 +#: website/public/index.html:1136 msgctxt "page:index.html" msgid "Browser observation guide" msgstr "瀏覽器觀察指南" -#: website/public/index.html:1138 +#: website/public/index.html:1143 msgctxt "page:index.html" msgid "Any questions?" msgstr "有什麼問題嗎?" @@ -6374,7 +6374,7 @@ msgstr "有什麼問題嗎?" #. {ph3}: #. {ph4}: #. {ph5}: -#: website/public/index.html:1139 +#: website/public/index.html:1144 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -6384,12 +6384,12 @@ msgstr "" "{ph0}閱讀 FAQ{ph1}、{ph2}在 Discord 和我們聊聊{ph3},或{ph4}提交 GitHub issue{ph5}反饋 bug " "和功能需求。" -#: website/public/index.html:1152 website/public/index.html:1158 +#: website/public/index.html:1157 website/public/index.html:1163 msgctxt "page:index.html" msgid "Join the community" msgstr "加入社群" -#: website/public/index.html:1153 +#: website/public/index.html:1158 msgctxt "page:index.html" msgid "" "Share investigations and get help from other REA users on Discord. Follow " @@ -6408,59 +6408,59 @@ msgid "" " Follow clear guides and try interactive examples with REA." msgstr "和程式設計助手一起分析應用、二進位制檔案和瀏覽器行為。跟著清晰的指南,體驗 REA 的互動示例。" -#: website/public/index.html:251 +#: website/public/index.html:256 msgctxt "page:index.html" msgid "Copy REA installation prompt" msgstr "複製REA安裝提示" -#: website/public/index.html:273 +#: website/public/index.html:278 msgctxt "page:index.html" msgid "Copy setup command" msgstr "複製安裝命令" -#: website/public/index.html:325 +#: website/public/index.html:330 msgctxt "page:index.html" msgid "Follow the manual Calculator analysis" msgstr "按照手動計算器分析" -#: website/public/index.html:488 +#: website/public/index.html:493 msgctxt "page:index.html" msgid "Copy Calculator percentage question" msgstr "複製計算器百分比問題" -#: website/public/index.html:595 +#: website/public/index.html:600 msgctxt "page:index.html" msgid "" "Dinosaur mini-game. Use the Jump button or press Space while the game is " "focused." msgstr "小恐龍游戲。點選“跳躍”,或在遊戲獲得焦點後按空格鍵。" -#: website/public/index.html:656 +#: website/public/index.html:661 msgctxt "page:index.html" msgid "Copy dinosaur analysis prompt" msgstr "複製恐龍分析提示" -#: website/public/index.html:812 +#: website/public/index.html:817 msgctxt "page:index.html" msgid "Choose the calculation" msgstr "選擇計算" -#: website/public/index.html:892 +#: website/public/index.html:897 msgctxt "page:index.html" msgid "Copy Calculator analysis prompt" msgstr "複製計算器分析提示" -#: website/public/index.html:1054 +#: website/public/index.html:1059 msgctxt "page:index.html" msgid "Copy website project prompt" msgstr "複製網站專案提示" -#: website/public/index.html:1082 +#: website/public/index.html:1087 msgctxt "page:index.html" msgid "Copy game reconstruction prompt" msgstr "複製遊戲重建提示" -#: website/public/index.html:1182 +#: website/public/index.html:1187 msgctxt "page:index.html" msgid "Back to top" msgstr "回到頂部" @@ -10633,26 +10633,6 @@ msgctxt "figure:th04-bullet-ring.svg" msgid "Same spacing: 256 ÷ 16 = 16 angle units = 22.5°" msgstr "相同間距:256÷16=16角度單位=22.5°" -#: website/public/blog/index.html:118 -#: website/public/blog/touhou-reconstruction/index.html:118 -#: website/public/examples/dino-lab/index.html:119 -#: website/public/faq/index.html:118 -#: website/public/first-investigation/index.html:118 -#: website/public/get-started/index.html:118 -#: website/public/guides/browser/index.html:118 -#: website/public/guides/index.html:118 -#: website/public/guides/javascript/index.html:118 -#: website/public/guides/native/index.html:118 website/public/index.html:120 -#: website/public/showcase/aegis/index.html:119 -#: website/public/showcase/ctf/index.html:118 -#: website/public/showcase/dx-ball/index.html:118 -#: website/public/showcase/index.html:118 -#: website/public/showcase/notion/index.html:118 -#: website/public/showcase/th04/index.html:118 -msgctxt "shared" -msgid "Showcases" -msgstr "案例展示" - #: website/public/blog/index.html:119 #: website/public/blog/touhou-reconstruction/index.html:119 #: website/public/examples/dino-lab/index.html:120 @@ -10670,8 +10650,8 @@ msgstr "案例展示" #: website/public/showcase/notion/index.html:119 #: website/public/showcase/th04/index.html:119 msgctxt "shared" -msgid "Blog" -msgstr "部落格" +msgid "Showcases" +msgstr "案例展示" #: website/public/blog/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:120 @@ -10690,6 +10670,26 @@ msgstr "部落格" #: website/public/showcase/notion/index.html:120 #: website/public/showcase/th04/index.html:120 msgctxt "shared" +msgid "Blog" +msgstr "部落格" + +#: website/public/blog/index.html:118 +#: website/public/blog/touhou-reconstruction/index.html:118 +#: website/public/examples/dino-lab/index.html:119 +#: website/public/faq/index.html:118 +#: website/public/first-investigation/index.html:118 +#: website/public/get-started/index.html:118 +#: website/public/guides/browser/index.html:118 +#: website/public/guides/index.html:118 +#: website/public/guides/javascript/index.html:118 +#: website/public/guides/native/index.html:118 website/public/index.html:120 +#: website/public/showcase/aegis/index.html:119 +#: website/public/showcase/ctf/index.html:118 +#: website/public/showcase/dx-ball/index.html:118 +#: website/public/showcase/index.html:118 +#: website/public/showcase/notion/index.html:118 +#: website/public/showcase/th04/index.html:118 +msgctxt "shared" msgid "Get started" msgstr "開始使用" @@ -10722,7 +10722,7 @@ msgstr "指南" #: website/public/guides/browser/index.html:486 #: website/public/guides/index.html:270 #: website/public/guides/javascript/index.html:491 -#: website/public/guides/native/index.html:497 website/public/index.html:1174 +#: website/public/guides/native/index.html:497 website/public/index.html:1179 #: website/public/showcase/aegis/index.html:796 #: website/public/showcase/ctf/index.html:765 #: website/public/showcase/dx-ball/index.html:773 @@ -10742,7 +10742,7 @@ msgstr "原始碼" #: website/public/guides/browser/index.html:487 #: website/public/guides/index.html:271 #: website/public/guides/javascript/index.html:492 -#: website/public/guides/native/index.html:498 website/public/index.html:1175 +#: website/public/guides/native/index.html:498 website/public/index.html:1180 #: website/public/showcase/aegis/index.html:797 #: website/public/showcase/ctf/index.html:766 #: website/public/showcase/dx-ball/index.html:774 @@ -10762,7 +10762,7 @@ msgstr "MIT 許可證" #: website/public/guides/browser/index.html:488 #: website/public/guides/index.html:272 #: website/public/guides/javascript/index.html:493 -#: website/public/guides/native/index.html:499 website/public/index.html:1176 +#: website/public/guides/native/index.html:499 website/public/index.html:1181 #: website/public/showcase/aegis/index.html:798 #: website/public/showcase/ctf/index.html:767 #: website/public/showcase/dx-ball/index.html:775 @@ -10782,7 +10782,7 @@ msgstr "加入社群" #: website/public/guides/browser/index.html:489 #: website/public/guides/index.html:273 #: website/public/guides/javascript/index.html:494 -#: website/public/guides/native/index.html:500 website/public/index.html:1177 +#: website/public/guides/native/index.html:500 website/public/index.html:1182 #: website/public/showcase/aegis/index.html:799 #: website/public/showcase/ctf/index.html:768 #: website/public/showcase/dx-ball/index.html:776 @@ -10893,37 +10893,32 @@ msgctxt "page:index.html" msgid "See how REA works ↓" msgstr "看看 REA 如何工作 ↓" -#: website/public/index.html:229 -msgctxt "page:index.html" -msgid "Setup guide" -msgstr "安裝指南" - -#: website/public/index.html:230 +#: website/public/index.html:235 msgctxt "page:index.html" msgid "Showcases →" msgstr "檢視案例 →" -#: website/public/index.html:740 +#: website/public/index.html:745 msgctxt "page:index.html" msgid "Selected settings from the inspected script" msgstr "實際指令碼中的設定節選" -#: website/public/index.html:912 +#: website/public/index.html:917 msgctxt "page:index.html" msgid "Readable summary of the % handler" msgstr "% 處理函式的可讀摘要" -#: website/public/index.html:976 +#: website/public/index.html:981 msgctxt "page:index.html" msgid "REA · selected original instructions" msgstr "REA · 原始指令節選" -#: website/public/index.html:987 +#: website/public/index.html:992 msgctxt "page:index.html" msgid "Operator IDs · Microsoft’s source" msgstr "運算子 ID · Microsoft 原始碼" -#: website/public/index.html:1161 +#: website/public/index.html:1166 msgctxt "page:index.html" msgid "Follow @reatools on X" msgstr "在X上關注@reatools" @@ -11328,7 +11323,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:260 +#: website/public/index.html:265 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11346,7 +11341,7 @@ msgstr "" #. {ph4}: #. {ph5}: -#: website/public/index.html:1063 +#: website/public/index.html:1068 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11555,7 +11550,7 @@ msgstr "下載示例並解壓縮。 存檔包含完整的 {ph0} 資料夾。 分 #. {ph0}: Node.js #. {ph1}: #. {ph2}: -#: website/public/index.html:285 +#: website/public/index.html:290 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11566,7 +11561,7 @@ msgstr "第一次使用,還沒安裝 {ph0} 或程式設計助手?{ph1}跟著 #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:309 +#: website/public/index.html:314 #, python-brace-format msgctxt "page:index.html" msgid "One question: {ph0}why does Calculator give 220 for {ph1}?{ph2}" @@ -11579,7 +11574,7 @@ msgstr "比如:{ph0}為什麼計算器算 {ph1} 會得到 220?{ph2}" #. {ph4}: #. {ph5}: -#: website/public/index.html:497 +#: website/public/index.html:502 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11589,7 +11584,7 @@ msgstr "" "{ph0}›{ph1}{ph2}用 REA 檢查 Windows 計算器。為什麼 {ph3} 會得到 220?{ph4}{ph5}{ph6}" #. {ph0}: index.js -#: website/public/index.html:682 +#: website/public/index.html:687 #, python-brace-format msgctxt "page:index.html" msgid "REA · loaded {ph0} · selected lines" @@ -11598,7 +11593,7 @@ msgstr "REA · 已載入 {ph0} · 程式碼節選" #. {ph0}: #. {ph1}: 200 + 10% #. {ph2}: -#: website/public/index.html:786 +#: website/public/index.html:791 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11614,7 +11609,7 @@ msgstr "我們已看到{ph0}為什麼 {ph1} 得到 220。{ph2}現在把 + 和 × #. {ph5}: #. {ph6}: ms-calculator: #. {ph7}: -#: website/public/index.html:943 +#: website/public/index.html:948 #, python-brace-format msgctxt "page:index.html" msgid "" @@ -11671,6 +11666,20 @@ msgctxt "page:showcase/notion/index.html" msgid "main/{ph0} · clipboard receiver" msgstr "主要/{ph0} · 剪貼簿接收器" +# Use the same label as the Get started navigation link. The arrow is +# decorative. +#. {ph0}: +#: website/public/index.html:229 +#, python-brace-format +msgctxt "page:index.html" +msgid "Get started {ph0}" +msgstr "開始使用 {ph0}" + +#~ msgctxt "page:index.html" +#~ msgid "Setup guide" +#~ msgstr "安裝指南" + #~ msgctxt "page:get-started/index.html" #~ msgid "Windows PowerShell" #~ msgstr "WindowsQ" diff --git a/website/public/assets/styles.css b/website/public/assets/styles.css index 8f8ac811cc..aa514ed6f5 100644 --- a/website/public/assets/styles.css +++ b/website/public/assets/styles.css @@ -159,6 +159,7 @@ section[id] { } .language-picker { position: relative; + min-inline-size: 0; font-size: 0.8rem; } .language-picker summary { @@ -174,11 +175,17 @@ section[id] { white-space: nowrap; list-style: none; } +.language-picker summary > span:last-child { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; +} .language-picker summary::-webkit-details-marker { display: none; } .language-picker summary::after { content: "⌄"; + flex-shrink: 0; } .language-picker nav { position: absolute; @@ -237,6 +244,7 @@ section[id] { .brand-name { font-size: 1.45rem; font-weight: 750; + white-space: nowrap; letter-spacing: -0.065em; } .brand-caption { @@ -379,6 +387,18 @@ section[id] { .button.secondary:hover { background: var(--soft); } +.button.home-setup-link { + gap: 0.5rem; + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} +.button.home-setup-link:hover { + background: #eef3fa; +} +html[dir="rtl"] .home-setup-link .button-arrow { + transform: scaleX(-1); +} .text-link { font-size: 0.95rem; font-weight: 500; @@ -2751,7 +2771,7 @@ html[dir="rtl"] .brand-name { } @media (max-width: 850px) { .header-inner { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr); } .brand-caption { display: none; @@ -2765,6 +2785,8 @@ html[dir="rtl"] .brand-name { .header-social { grid-column: 2; grid-row: 1; + min-inline-size: 0; + justify-content: flex-end; gap: 0.45rem; } .nav-github-count { @@ -2783,6 +2805,12 @@ html[dir="rtl"] .brand-name { } } @media (max-width: 400px) { + .header-inner { + column-gap: 1rem; + } + .language-picker summary > span[aria-hidden="true"] { + display: none; + } .header-social .nav-x { display: none; } diff --git a/website/public/blog/index.html b/website/public/blog/index.html index 3bbb105362..509d85ba6a 100644 --- a/website/public/blog/index.html +++ b/website/public/blog/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/blog/touhou-reconstruction/index.html b/website/public/blog/touhou-reconstruction/index.html index 5b1d47497d..9e2051c6b6 100644 --- a/website/public/blog/touhou-reconstruction/index.html +++ b/website/public/blog/touhou-reconstruction/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/examples/dino-lab/index.html b/website/public/examples/dino-lab/index.html index 3fb2ec6fa8..982384207d 100644 --- a/website/public/examples/dino-lab/index.html +++ b/website/public/examples/dino-lab/index.html @@ -116,9 +116,9 @@ >Reverse Engineer Anything diff --git a/website/public/faq/index.html b/website/public/faq/index.html index a77d330b0d..b0f5f26d1c 100644 --- a/website/public/faq/index.html +++ b/website/public/faq/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/first-investigation/index.html b/website/public/first-investigation/index.html index 9d35804fa1..ab06f31988 100644 --- a/website/public/first-investigation/index.html +++ b/website/public/first-investigation/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/get-started/index.html b/website/public/get-started/index.html index 45e767b3a6..5eda9f1b33 100644 --- a/website/public/get-started/index.html +++ b/website/public/get-started/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/guides/browser/index.html b/website/public/guides/browser/index.html index 9e1825a1bb..62c9a91fe5 100644 --- a/website/public/guides/browser/index.html +++ b/website/public/guides/browser/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/guides/index.html b/website/public/guides/index.html index 8806ddbaa1..ebf5581bf1 100644 --- a/website/public/guides/index.html +++ b/website/public/guides/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/guides/javascript/index.html b/website/public/guides/javascript/index.html index 3ec2980455..00844bc044 100644 --- a/website/public/guides/javascript/index.html +++ b/website/public/guides/javascript/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/guides/native/index.html b/website/public/guides/native/index.html index 3069737615..e24777e809 100644 --- a/website/public/guides/native/index.html +++ b/website/public/guides/native/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/index.html b/website/public/index.html index 5c5793b1ad..007fd55677 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -117,9 +117,9 @@ >Reverse Engineer Anything @@ -226,7 +226,12 @@

diff --git a/website/public/showcase/aegis/index.html b/website/public/showcase/aegis/index.html index faac483c4b..c3f34f3dc2 100644 --- a/website/public/showcase/aegis/index.html +++ b/website/public/showcase/aegis/index.html @@ -116,9 +116,9 @@ >Reverse Engineer Anything

diff --git a/website/public/showcase/ctf/index.html b/website/public/showcase/ctf/index.html index a09acbb238..835368685f 100644 --- a/website/public/showcase/ctf/index.html +++ b/website/public/showcase/ctf/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/showcase/dx-ball/index.html b/website/public/showcase/dx-ball/index.html index b7967c8961..e109a01ed5 100644 --- a/website/public/showcase/dx-ball/index.html +++ b/website/public/showcase/dx-ball/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/showcase/index.html b/website/public/showcase/index.html index 7841ec0db8..471e9b229e 100644 --- a/website/public/showcase/index.html +++ b/website/public/showcase/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/showcase/notion/index.html b/website/public/showcase/notion/index.html index 00facb89b2..9332eeece2 100644 --- a/website/public/showcase/notion/index.html +++ b/website/public/showcase/notion/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything diff --git a/website/public/showcase/th04/index.html b/website/public/showcase/th04/index.html index 9000927e08..3d95186ce0 100644 --- a/website/public/showcase/th04/index.html +++ b/website/public/showcase/th04/index.html @@ -115,9 +115,9 @@ >Reverse Engineer Anything From d96550f2f61647ffa150597de73363fa90c4e169 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:02:35 +0800 Subject: [PATCH 21/93] ci: summarize static checks and retain complete reports (#2242) Keep static feedback concise while retaining complete diagnostic reports, visible lint errors, module-boundary checks, and the 3% duplication gate. Upload complete reports after static failures and restore their outputs from Turbo caches. Preserve unambiguous, checkout-relative clone locations. Bind both lint caches to the website, documentation configuration and experiment sources actually scanned by oxlint, including ignore policy. Guard this ownership by comparing live checker discovery with resolved Turbo inputs. A real website syntax error now invalidates a cached pass. Validation: full current-head CI and the separate Go lane passed. Seven real-checker regressions passed; failure-status and stale-report mutations were rejected, and restored source passed. Actual Turbo restored both reports byte-for-byte; full static/docs/workflow checks passed. Closes #2238. Refs #2190. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- .github/workflows/ci.yml | 9 + .jscpd.json | 1 - docs/testing.md | 10 + package.json | 6 +- scripts/ci/static-check-report.mjs | 149 ++++++++++++ .../filesystem/staticCheckReports.test.ts | 214 ++++++++++++++++++ turbo.json | 22 +- 7 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/static-check-report.mjs create mode 100644 tests/boundary/filesystem/staticCheckReports.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c026177..1514de8511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,15 @@ jobs: run: npm run test:python - if: needs.changes.outputs.code != 'true' run: npm run format:check + - name: Upload complete static diagnostics + if: always() && needs.changes.outputs.code == 'true' + uses: actions/upload-artifact@v4 + with: + name: static-check-reports + path: .cache/static-checks/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 test-shard: name: Test shard (${{ matrix.shard }}) diff --git a/.jscpd.json b/.jscpd.json index 8c6cb653f1..c8e89d7914 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -1,5 +1,4 @@ { - "_comment": "Test files remain included so the gate measures duplication across both src/ and tests/. The 3.0% limit leaves headroom for ordinary changes while keeping duplication visible.", "threshold": 3.0, "reporters": ["consoleFull"], "ignore": [ diff --git a/docs/testing.md b/docs/testing.md index 7bb14effeb..c7fb109df0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,6 +68,16 @@ correctness. Select relevant boundary files and real-provider lanes explicitly. Source projects also contain large capacity regressions; `test:fast` promises no compiled-runtime prerequisite, not a fixed time budget. +`npm run lint` prints errors and a warning summary; `npm run jscpd` prints +duplication statistics. Complete reports remain in `.cache/static-checks/` and +CI uploads them as `static-check-reports`, including after a failed static gate. +Use `npm run lint:details` or `npm run jscpd:details` for all console diagnostics. +Both duplicate commands scan production and test code with the same 3% limit; +the summary commands preserve checker failures and restore reports from cached +static tasks. Both lint caches include the website, documentation configuration, +and experiment code scanned by the checker; a regression compares the actual +lint inventory with Turbo's resolved inputs. + Routine iterations and rebases need focused regressions and relevant checks. Before handing off a PR, run `npm run check` and generated-document checks when applicable; CI owns the full suite and coverage. Use the full local gate for diff --git a/package.json b/package.json index dd0ef57d80..faefead3b1 100644 --- a/package.json +++ b/package.json @@ -118,11 +118,13 @@ "build:termux:run": "npm run compile:termux && npm run artifacts:skills", "format": "oxfmt .", "format:check": "oxfmt --check .", - "lint": "oxlint && npm run verify:module-boundaries", + "lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries", + "lint:details": "oxlint && npm run verify:module-boundaries", "lint:built": "npm run lint", "lint:fix": "oxlint --fix && npm run verify:module-boundaries", "knip": "knip", - "jscpd": "jscpd src/ tests/", + "jscpd": "node scripts/ci/static-check-report.mjs duplicates", + "jscpd:details": "jscpd src/ tests/ --no-tips", "scan:todos": "node scripts/scan-todos.mjs", "start": "node scripts/rea.mjs mcp", "test": "npm run test:with-build", diff --git a/scripts/ci/static-check-report.mjs b/scripts/ci/static-check-report.mjs new file mode 100644 index 0000000000..04460b2c20 --- /dev/null +++ b/scripts/ci/static-check-report.mjs @@ -0,0 +1,149 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative, sep } from "node:path"; +import { z } from "zod"; + +const require = createRequire(import.meta.url); +const mode = process.argv[2]; +const reportRoot = join(".cache", "static-checks"); +const extraArguments = process.argv.slice(3); +const lintReport = z.object({ + number_of_files: z.number(), + diagnostics: z.array( + z.object({ + message: z.string(), + code: z.string().optional(), + severity: z.enum(["error", "warning", "advice"]), + filename: z.string(), + labels: z.array( + z.object({ + span: z.object({ line: z.number(), column: z.number() }), + }), + ), + }), + ), +}); +const duplicateReport = z + .object({ + duplicates: z.array( + z + .object({ + firstFile: z.object({ name: z.string() }).passthrough(), + secondFile: z.object({ name: z.string() }).passthrough(), + }) + .passthrough(), + ), + statistics: z + .object({ + total: z + .object({ + sources: z.number(), + clones: z.number(), + percentage: z.number(), + }) + .passthrough(), + }) + .passthrough(), + }) + .passthrough(); + +const run = (packageName, entrypoint, arguments_) => { + const executable = join( + dirname(require.resolve(`${packageName}/package.json`)), + entrypoint, + ); + const result = spawnSync(process.execPath, [executable, ...arguments_], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error !== undefined) throw result.error; + if (result.status === null) + throw new Error(`${packageName} terminated by ${result.signal}`); + return result; +}; + +try { + await mkdir(reportRoot, { recursive: true }); + if (mode === "lint") { + const reportPath = join(reportRoot, "oxlint.json"); + await rm(reportPath, { force: true }); + const result = run("oxlint", "bin/oxlint", [ + ...extraArguments, + "--format", + "json", + ]); + let report; + try { + report = lintReport.parse(JSON.parse(result.stdout)); + } catch (cause) { + process.stderr.write(result.stdout); + throw cause; + } + await writeFile(reportPath, result.stdout); + const warnings = report.diagnostics.filter( + ({ severity }) => severity === "warning", + ); + const errors = report.diagnostics.filter( + ({ severity }) => severity === "error", + ); + console.log( + `Lint: ${report.number_of_files} files, ${errors.length} errors, ${warnings.length} warnings.`, + ); + for (const diagnostic of errors) { + const location = diagnostic.labels[0]?.span; + console.error( + `${diagnostic.filename}:${location?.line ?? 1}:${location?.column ?? 1}: ${diagnostic.code ?? "lint"}: ${diagnostic.message}`, + ); + } + const counts = new Map(); + for (const { code } of warnings) + counts.set(code ?? "warning", (counts.get(code ?? "warning") ?? 0) + 1); + for (const [rule, count] of [...counts] + .sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .slice(0, 8)) + console.log(` ${rule}: ${count} warnings`); + console.log(`Full diagnostics: ${reportPath}; npm run lint:details`); + process.exitCode = result.status; + } else if (mode === "duplicates") { + const output = join(reportRoot, "jscpd"); + const reportPath = join(output, "jscpd-report.json"); + await rm(reportPath, { force: true }); + const result = run("jscpd", "run-jscpd.js", [ + "src/", + "tests/", + ...extraArguments, + "--reporters", + "json", + "--output", + output, + "--no-colors", + "--no-tips", + "--absolute", + ]); + if (result.stdout) process.stdout.write(result.stdout); + const report = duplicateReport.parse( + JSON.parse(await readFile(reportPath, "utf8")), + ); + // jscpd otherwise strips each scan root, making src/ and tests/ names + // ambiguous. Normalize absolute producer paths once for portable reports. + for (const clone of report.duplicates) + for (const file of [clone.firstFile, clone.secondFile]) + file.name = relative(process.cwd(), file.name).split(sep).join("/"); + await writeFile(reportPath, JSON.stringify(report, null, 2)); + const { sources, clones, percentage } = report.statistics.total; + console.log( + `Duplication: ${sources} files, ${clones} clones, ${percentage.toFixed(2)}% duplicated lines.`, + ); + console.log(`Full diagnostics: ${reportPath}; npm run jscpd:details`); + process.exitCode = result.status; + } else { + throw new Error("Expected lint or duplicates"); + } +} catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exitCode = 1; +} diff --git a/tests/boundary/filesystem/staticCheckReports.test.ts b/tests/boundary/filesystem/staticCheckReports.test.ts new file mode 100644 index 0000000000..c2708e1dba --- /dev/null +++ b/tests/boundary/filesystem/staticCheckReports.test.ts @@ -0,0 +1,214 @@ +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const script = fileURLToPath( + new URL("../../../scripts/ci/static-check-report.mjs", import.meta.url), +); +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const fixture = async () => { + const root = await createTestTempDirectory("rea-static-report-"); + await Promise.all([mkdir(join(root, "src")), mkdir(join(root, "tests"))]); + await writeFile( + join(root, ".oxlintrc.json"), + JSON.stringify({ + rules: { "no-debugger": "error", "no-unused-vars": "warn" }, + }), + ); + await writeFile( + join(root, ".jscpd.json"), + JSON.stringify({ threshold: 3, minLines: 5, minTokens: 50 }), + ); + return root; +}; +const invoke = (root: string, mode: string, ...arguments_: string[]) => + spawnSync(process.execPath, [script, mode, ...arguments_], { + cwd: root, + encoding: "utf8", + timeout: 15_000, + }); +const lintDiagnostics = async (root: string) => + z + .object({ + diagnostics: z.array( + z.object({ + severity: z.string(), + code: z.string().optional(), + filename: z.string(), + }), + ), + }) + .parse( + JSON.parse( + await readFile(join(root, ".cache/static-checks/oxlint.json"), "utf8"), + ), + ).diagnostics; + +it("includes every actually linted file in both cached lint task inputs", () => { + const scan = spawnSync( + process.execPath, + [join(repository, "node_modules/oxlint/bin/oxlint"), "--debug=files"], + { cwd: repository, encoding: "utf8", timeout: 15_000 }, + ); + expect(scan.error).toBeUndefined(); + expect(scan.status, scan.stderr).toBe(0); + const files = scan.stdout + .trim() + .split(/\r?\n/u) + .map((path) => path.split(sep).join("/")); + expect(files.length).toBeGreaterThan(0); + const dry = spawnSync( + process.execPath, + [ + join(repository, "node_modules/turbo/bin/turbo"), + "run", + "lint", + "lint:built", + "--dry=json", + ], + { + cwd: repository, + encoding: "utf8", + timeout: 15_000, + maxBuffer: 16 * 1024 * 1024, + }, + ); + expect(dry.error).toBeUndefined(); + expect(dry.status, dry.stderr).toBe(0); + const { tasks } = z + .object({ + tasks: z.array( + z.object({ + task: z.string(), + inputs: z.record(z.string(), z.string()), + }), + ), + }) + .parse(JSON.parse(dry.stdout)); + const lintTasks = tasks.filter(({ task }) => + ["lint", "lint:built"].includes(task), + ); + expect(lintTasks).toHaveLength(2); + for (const task of lintTasks) + expect( + files.filter((file) => !Object.hasOwn(task.inputs, file)), + `${task.task} omits actual checker inputs`, + ).toEqual([]); +}); + +it("summarizes warnings without losing their complete diagnostic records", async () => { + const root = await fixture(); + await writeFile(join(root, "src/warning.js"), "const forgotten = 1;\n"); + const result = invoke(root, "lint"); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0 errors, 1 warnings"); + expect(result.stdout.split("\n").length).toBeLessThan(10); + expect(await lintDiagnostics(root)).toEqual([ + expect.objectContaining({ + severity: "warning", + code: "eslint(no-unused-vars)", + filename: "src/warning.js", + }), + ]); +}); + +it("keeps lint errors failing and visible alongside the retained warnings", async () => { + const root = await fixture(); + await writeFile( + join(root, "src/fault.js"), + "const forgotten = 1;\ndebugger;\n", + ); + const result = invoke(root, "lint"); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toContain("1 errors, 1 warnings"); + expect(result.stderr).toContain("src/fault.js:2:1"); + expect(result.stderr).toContain("eslint(no-debugger)"); + expect(await lintDiagnostics(root)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ severity: "warning" }), + expect.objectContaining({ severity: "error" }), + ]), + ); +}); + +it("keeps malformed source failing with its parser diagnostic", async () => { + const root = await fixture(); + await writeFile(join(root, "src/malformed.js"), "const value = ;\n"); + const result = invoke(root, "lint"); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("src/malformed.js:1:"); + expect(await lintDiagnostics(root)).toContainEqual( + expect.objectContaining({ severity: "error" }), + ); +}); + +it("preserves the duplication threshold and complete clone locations", async () => { + const root = await fixture(); + const source = [ + "export function calculate(value) {", + ...Array.from({ length: 34 }, (_, index) => ` value += ${index + 1};`), + " return value;", + "}", + ].join("\n"); + await Promise.all([ + writeFile(join(root, "src/same.ts"), source), + writeFile(join(root, "tests/same.ts"), source), + ]); + const result = invoke(root, "duplicates"); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toContain("Duplication:"); + expect(result.stdout.split("\n").length).toBeLessThan(15); + const report = z + .object({ + statistics: z.object({ total: z.object({ percentage: z.number() }) }), + duplicates: z.array( + z.object({ + firstFile: z.object({ name: z.string() }), + secondFile: z.object({ name: z.string() }), + }), + ), + }) + .parse( + JSON.parse( + await readFile( + join(root, ".cache/static-checks/jscpd/jscpd-report.json"), + "utf8", + ), + ), + ); + expect(report.statistics.total.percentage).toBeGreaterThan(3); + expect(report.duplicates).toHaveLength(1); + expect( + [ + report.duplicates[0]?.firstFile.name, + report.duplicates[0]?.secondFile.name, + ].sort(), + ).toEqual(["src/same.ts", "tests/same.ts"]); +}); + +it.each([ + ["lint", "oxlint.json"], + ["duplicates", "jscpd/jscpd-report.json"], +])( + "removes stale %s evidence when the checker cannot run", + async (mode, name) => { + const root = await fixture(); + const reportPath = join(root, ".cache/static-checks", name); + await mkdir(join(root, ".cache/static-checks/jscpd"), { recursive: true }); + await writeFile(reportPath, '{"previous_success":true}'); + const result = invoke(root, mode, "--rea-invalid-checker-option"); + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("rea-invalid-checker-option"); + await expect(access(reportPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/turbo.json b/turbo.json index 8de89672db..e8bfbf06e8 100644 --- a/turbo.json +++ b/turbo.json @@ -46,7 +46,9 @@ ] }, "lint": { + "outputs": [".cache/static-checks/oxlint.json"], "inputs": [ + ".gitignore", ".nvmrc", ".oxlintrc.json", "package.json", @@ -54,12 +56,21 @@ "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", "src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", "tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "experiments/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "!docs/.vitepress/cache/**", + "!docs/.vitepress/dist/**", + "!website/_site/**", + "!experiments/analysis-efficiency/results/**", "vitest.config.ts" ] }, "lint:built": { "dependsOn": ["compile"], + "outputs": [".cache/static-checks/oxlint.json"], "inputs": [ + ".gitignore", ".nvmrc", ".oxlintrc.json", "package.json", @@ -67,6 +78,13 @@ "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", "src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", "tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "experiments/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", + "!docs/.vitepress/cache/**", + "!docs/.vitepress/dist/**", + "!website/_site/**", + "!experiments/analysis-efficiency/results/**", "vitest.config.ts" ] }, @@ -91,9 +109,11 @@ ".jscpd.json", "package.json", "package-lock.json", + "scripts/ci/static-check-report.mjs", "src/**", "tests/**" - ] + ], + "outputs": [".cache/static-checks/jscpd/jscpd-report.json"] }, "scan:todos": { "inputs": [ From ad08510a113d7a9b8d92fb1e385757880e469869 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:26:43 +0800 Subject: [PATCH 22/93] refactor(evm): bind offline analysis through a static descriptor Bind existing EVM CLI/MCP metadata and the legacy service factory through one static descriptor, and register the same selected service that owns cleanup. Preserve original names, schemas, inputs/full defaults, configuration, Evidence and injection identity. Verified exact SDK catalog/fact parity across six real EVM pairs, 59 actual EVM CLI/MCP cases, six real cross-domain workflows, six actual Codex CLI fact/reference/lifecycle trials and relevant local checks. CI run 38283026233 succeeds for exact head d999d9874bf59c3c8486bac9b2c867a4a6a9121d. No token reduction from wiring or fully qualified resource claim; final counters remain unknown. Continues #2016. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/architecture.mermaid | 2 +- experiments/analysis-efficiency/README.md | 9 +- experiments/analysis-efficiency/corpus.json | 56 ++++++ .../analysis-efficiency/evm-descriptor.md | 181 ++++++++++++++++++ experiments/analysis-efficiency/measure.mjs | 52 +++-- .../questions/evm-uniswap.txt | 1 + .../verify-evm-template.mjs | 95 +++++++++ src/cli/evmCommands.ts | 9 +- src/composition/evm.ts | 13 ++ src/contracts/evm/evmToolContracts.ts | 33 ++-- src/server/createServer.ts | 8 +- src/server/registerEvmTools.ts | 4 +- tests/boundary/mcp/evmInterfaceMcp.test.ts | 175 ++++++++++++++++- 13 files changed, 590 insertions(+), 48 deletions(-) create mode 100644 experiments/analysis-efficiency/evm-descriptor.md create mode 100644 experiments/analysis-efficiency/questions/evm-uniswap.txt create mode 100644 experiments/analysis-efficiency/verify-evm-template.mjs diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 33725582dc..753c57024e 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -6,7 +6,7 @@ graph TD end subgraph "Production Composition" - COMPOSITION["src/composition/
fresh typed provider and session factories
one-shot binding and independent optional loading
static WASM descriptor: named contract, factory, retryable lifetime"] + COMPOSITION["src/composition/
fresh typed provider and session factories
one-shot binding and independent optional loading
static WASM/EVM descriptors: named contracts, factories, retryable lifetimes"] end subgraph "Adapters" diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 834d717cd8..25438895da 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -11,6 +11,10 @@ Follow-up: [native PE/DOS entry views and real model verification](native-entry- records the post-merge native continuation, including a small-function case where four selected replies cost more bytes than the complete dossier. +Follow-up: [EVM descriptor and real-template controls](evm-descriptor.md) extends +the offline corpus with digest-pinned Uniswap runtime templates and an independent +published ABI selector oracle, preserving unknown deployment/immutable metadata. + The 12 detailed checkpoint receipts were archived byte-for-byte before removal from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the private experiment lab outside the checkout; its `archive-index.json` records @@ -40,8 +44,9 @@ acceptance conditions for every optimization. experiment control; it does not itself establish reduced CPU consumption. Migrate domain by domain with focused real-artifact checks and relevant shared -contract checks. The domain matrix is a growing roadmap; native PE and DOS, for -example, remain unverified in this study. A passed lane does not imply all +contract checks. The native follow-up verifies selected PE and DOS MZ entry facts; +DOS COM and complete native application behavior remain unverified. The domain +matrix is a growing roadmap. A passed lane does not imply all advertised platforms/providers have passed. Newly discovered REA bugs remain in the separate local handoff document for later team repair. diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json index 5d2ad531dd..540e88cddf 100644 --- a/experiments/analysis-efficiency/corpus.json +++ b/experiments/analysis-efficiency/corpus.json @@ -179,6 +179,62 @@ "transport_integrity": "sha512-RJFoomET6FajjG511fmQxeBQfU6M24a0aFZPqpid+ttIxanWf1VGytBG0UmsGjt07qmIPJS8U31D+aecuCucsQ==", "derivation": "selected regular tar member package/wasm/tree-sitter-css.wasm; no target execution" } + }, + { + "id": "uniswap-v3.factory.hex", + "file": "UniswapV3Factory.runtime.hex", + "bytes": 49073, + "sha256": "fb152cc9b597ef4e54e9d72e81dd72c202050aae5e98c63df139864274504276", + "decoded_bytes": 24535, + "decoded_sha256": "95c34fe1e6b26c79ae42800041a3e2bdb6a978d19297cd74001ac6b316a48d8d", + "provenance": { + "project": "Uniswap v3-core UniswapV3Factory", + "version": "1.0.1", + "url": "https://registry.npmjs.org/@uniswap/v3-core/-/v3-core-1.0.1.tgz", + "transport_bytes": 86371, + "transport_sha256": "141b001e2a376fff867cb24e0713b2834095cb1250c13aae263d9a646446d329", + "transport_integrity": "sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==", + "archive_member": "package/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json", + "compiler_artifact_bytes": 104076, + "compiler_artifact_sha256": "599479f60ebb056804aff7b2d05bdd0830ddbb1fdfaa0b6c62c02294ca7188b0", + "derivation": "Parse the exact published Hardhat deployedBytecode field and preserve its original hex carrier with one trailing LF; preserve template placeholders", + "authority": "Published compiler runtime template, not a live deployed instance; original placeholders/immutables preserved; no execution or chain lookup", + "published_abi_function_count": 7, + "deployed_link_references": {}, + "immutable_references": { + "recorded": false, + "value": null + }, + "immutable_scope": "Published Hardhat artifact does not record an immutable-reference map; absence is unknown, not proof of no immutables. Runtime template bytes remain exact." + } + }, + { + "id": "uniswap-v3.pool.hex", + "file": "UniswapV3Pool.runtime.hex", + "bytes": 44287, + "sha256": "84d90218eb125074383287877cdc564eca3765241e3bf6f6afdd64a922fbed2b", + "decoded_bytes": 22142, + "decoded_sha256": "3ef71385c2a22b945bead7904f24b25db877de9db51d1401f2f9daea07ea5c0c", + "provenance": { + "project": "Uniswap v3-core UniswapV3Pool", + "version": "1.0.1", + "url": "https://registry.npmjs.org/@uniswap/v3-core/-/v3-core-1.0.1.tgz", + "transport_bytes": 86371, + "transport_sha256": "141b001e2a376fff867cb24e0713b2834095cb1250c13aae263d9a646446d329", + "transport_integrity": "sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==", + "archive_member": "package/artifacts/contracts/UniswapV3Pool.sol/UniswapV3Pool.json", + "compiler_artifact_bytes": 111557, + "compiler_artifact_sha256": "204fb2fa7ad1a639b1df5601f035f9924b064b721b97adf60c7d5d4712643e34", + "derivation": "Parse the exact published Hardhat deployedBytecode field and preserve its original hex carrier with one trailing LF; preserve template placeholders", + "authority": "Published compiler runtime template, not a live deployed instance; original placeholders/immutables preserved; no execution or chain lookup", + "published_abi_function_count": 26, + "deployed_link_references": {}, + "immutable_references": { + "recorded": false, + "value": null + }, + "immutable_scope": "Published Hardhat artifact does not record an immutable-reference map; absence is unknown, not proof of no immutables. Runtime template bytes remain exact." + } } ] } diff --git a/experiments/analysis-efficiency/evm-descriptor.md b/experiments/analysis-efficiency/evm-descriptor.md new file mode 100644 index 0000000000..8e3611f282 --- /dev/null +++ b/experiments/analysis-efficiency/evm-descriptor.md @@ -0,0 +1,181 @@ +# EVM descriptor and real-template checkpoint + +Continues [#2016](https://github.com/morluto/rea/issues/2016) after the native +checkpoint merged. The internal descriptor binds the existing exact EVM +contract, CLI name and typed service lifetime. Existing complete inputs, output, +provider selection and cleanup remain the baseline. Wiring alone does not establish +token or latency savings. + +The descriptor contains only the named contract, existing CLI name and existing +factory. The legacy `createEvmInterfaceService` export remains usable. The MCP +host selects one complete service instance, preserves injection identity, passes +that same instance to registration and retains it for cleanup/retry. No bound +service wrapper, generic registry, automatic dependency acquisition or new pack +selection is introduced. + +Five actual in-memory SDK boundary scenarios verify converted schemas, duplicate +registration rejection before use, malformed inputs, explicit unavailable or +incompatible-output failures with a healthy WASM peer, cleanup retry, and request +cancellation followed by recovery. Their injected ports are recording seams, not +real EVMole/WABT claims. Nine service and 26 provider-boundary cases also pass; +two unsupported-host scenarios are skipped on this Linux host. The existing seven +WASM MCP scenarios pass. Full `npm run check` passes; its approximately 3 GiB +sampled peak and `memory.high` events are excluded from performance claims. +Candidate real-artifact/catalog and model observations are recorded below. + +## Public inputs and independent checks + +[corpus.json](corpus.json) retains the original WETH historical example and adds +Uniswap v3-core 1.0.1 Factory/Pool compiler runtime templates from the official npm +package. Archive integrity, exact regular member, compiler JSON, original carrier +and decoded-byte digests are pinned. The carrier is the original `deployedBytecode` +field plus one trailing LF. No placeholder is rewritten and no contract is executed. + +These are compiler templates, not live deployed instances. Their published Hardhat +artifacts omit immutable-reference maps; that metadata remains explicitly unknown. +The recorded empty link-reference maps do not establish absence of immutables. +WETH remains a published historical RPC example, with unknown live authenticity. + +Actual control CLI observations infer 7 Factory and 26 Pool functions. The +[independent verifier](verify-evm-template.mjs) hashes each canonical published ABI +signature with caller-supplied Ethereum Keccak and checks the complete ABI-dispatch +selector multiset against the original REA response. Both sets match. This does not +establish inferred argument/mutability equivalence, deployment authenticity or +runtime semantics; all producer unknowns remain visible. + +## Latest-main control + +Production control: `1770f38cfc6cbe87491d0de8b8730c305c5cf8ea`, Node 24.18.0, +npm 11.16.0, matching locked dependencies and unchanged EVMole 0.9.3. +The frozen `dist/`/`bridge/` file-table SHA-256 is +`7fd6c2c37b4e166df158ec29d48d31f3712b9b087d9807ee019ee997d06ea144`. +Measurement driver SHA-256 is +`f767c0c0232be1e11918e70e3197a6919680d16688076bef28d4320662f712f7`. +Each public SDK run validates the selected named advertised schema and verifies +original carrier and decoded identities, inference/unknown scope and unchanged +input bytes. All three runs return the same complete 2,420,709-byte catalog. + +| Question | Inferred functions | SDK tool-result JSON bytes | First workflow seconds | +| ------------------------ | -----------------: | -------------------------: | ---------------------: | +| WETH historical carrier | 11 | 26,760 | 11.432 | +| Uniswap Factory template | 7 | 110,424 | 3.628 | +| Uniswap Pool template | 26 | 110,998 | 11.211 | + +These are initial observations, not paired performance conclusions or model-token +counts. Each workflow includes connection, complete catalog discovery and one +interface query. Full bytecode remains inline. Candidate/control repetitions must +use the same source driver, inputs, provider settings and Node/worker limits. + +Preparation against earlier frozen main `b1bf4a70d44191348ca20a91a52fb663da4daed0` +also passed all 59 cases of the existing real CLI/MCP EVM verifier with privately +supplied solc 0.8.30 and strace 6.1. Those cases cover compiler configurations, +raw/hex identity, malformed/unsupported carriers, missing limiter and resource +constraints. Its measured scope took 233.1 seconds, sampled peak 1,192,730,624 +bytes and last-observed CPU 181.018 seconds. This is a control, not candidate proof. + +All heavy runs are serial under one CPU, 4 GiB hard memory/3 GiB early-stop, no swap +and bounded tasks. Final cgroup counters disappear at scope teardown and remain +unverified; last-observed zero limit events are not substituted for final counters. +Raw replies, tools, compiler artifacts and detailed receipts remain outside Git. + +## Reproduction + +Build and freeze each pinned runtime using the repository toolchain, then run +`measure.mjs --artifact uniswap-v3.factory.hex` or `uniswap-v3.pool.hex` with the +exact derived carrier, `--schemas selected`, explicit runtime/revision and a fresh +private output directory. Existing `weth.hex` answer fields and invocation remain +unchanged. The same common workflow verifies all three offline carriers. + +Run `verify-evm-template.mjs` with `--artifact` selecting the corpus ID, +`--artifact-json` selecting its original published compiler JSON, `--evidence` +selecting the recorded CLI Evidence or SDK structured reply, `--keccak-module` +selecting the absolute `js-sha3/src/sha3.js` module from the private solc toolchain, +and `--output` selecting a new private receipt. It acquires no dependencies and +does not overwrite an existing receipt. + +The [template model question](questions/evm-uniswap.txt) requires the same complete +inferred interface facts and explicit unknowns. An ABI-count match is not used as +a model grade. + +## Candidate compatibility and real-provider verification + +Candidate: `152984da771450048ee9d60db8a410994a125878`, frozen runtime file-table +SHA-256 `725563640c27eb50ddb6f977d2dfbe1bdaddf7e05c02897318bceaa36fa602d7`. +Two repetitions for each EVM input produce six passing paired fact/settings +comparisons with the same driver and explicit limits. Each report preserves both +original Evidence reference maps. The complete actual SDK catalog is byte-identical +on every pair, including order, names, descriptions, schemas and annotations; +catalog SHA-256 is +`8664e42fd5225645cfd910f440f731c67ac198d3d53aa85b5c485374c74cf603`. +Result byte counts remain unchanged. Candidate Factory/Pool selectors also match +the independent published ABI oracle. + +| Question | Control seconds, repetitions 1 / 2 | Candidate seconds, repetitions 1 / 2 | +| -------- | ---------------------------------: | -----------------------------------: | +| Factory | 3.628 / 3.650 | 3.708 / 3.605 | +| Pool | 11.211 / 3.591 | 3.749 / 3.581 | +| WETH | 11.432 / 3.694 | 3.572 / 3.677 | + +First controls preceded wiring; each second pair ran candidate then control. +Every measurement starts a new MCP process; host cache/page state is not fully +controlled. Slower initial controls do not establish a descriptor speedup. +Second-pair latency and last-observed CPU are similar. Sampled EVM SDK family peaks +range from 510,255,104 to 527,622,144 bytes. No scope abort occurred; final counters +remain unknown, so these measurements are descriptive. + +The candidate passes all 59 existing real CLI/MCP verifier cases with solc 0.8.30 +and unchanged EVMole 0.9.3. Actual syscall traces report target execution and network +sockets absent for those scenarios, and the verifier reports no remaining +descendants. The scope took 177.456 seconds, sampled peak 1,237,020,672 bytes and +last-observed CPU 144.913 seconds. Different totals from the earlier control are +not attributed to unchanged provider algorithms. + +Post-descriptor public MCP regression uses the original pinned shipped corpus: + +| Domain / selected question | Actual provider / scope | Calls | Seconds | +| ------------------------------------------- | -------------------------------------------------- | ----: | ------: | +| SQLite WASM counts / exports | WABT 1.0.42; selected summary and retained read | 2 | 5.516 | +| ShareX managed metadata / native boundaries | Static PE/CLI, no assembly execution | 2 | 3.710 | +| Obsidian manifest / inventory | Complete inventory retained, no AST claim | 4 | 5.376 | +| NewPipe package / MainActivity methods | Real headless JADX 0.7.1 | 3 | 21.669 | +| OpenWrt regions | Real firmware signature scan, no device execution | 1 | 3.581 | +| jq native entry | Ghidra 12.1.4, default analysis, ephemeral Program | 4 | 270.986 | + +All six workflows pass named SDK output validation and original digest checks; +input bytes remain unchanged. Maximum sampled family peak is 1,284,648,960 bytes. +They establish those available Linux workflows, not paired performance or +Windows/macOS, IDA/Hopper, Android-device, DOS COM, browser or JavaScript AST +behavior. Browser/AST were not repeated for this narrow migration. + +## Real model complete-output calibration + +Six serial Codex CLI 0.161.0 trials request `gpt-5.6-terra`, high reasoning, with +isolated temporary profiles pointing to the frozen candidate. Resolved server-side +model identity is not inferred. Only the EVM inspection tool is enabled, matching +the earlier WETH calibration; this is not full-catalog tool selection verification. +Every trial makes one successful call and no failed call. + +| Trial | Total input | Uncached input | Output | Seconds | +| --------- | ----------: | -------------: | -----: | ------: | +| Factory 1 | 66,158 | 36,974 | 1,329 | 43.483 | +| Pool 1 | 65,600 | 36,416 | 2,307 | 69.546 | +| WETH 1 | 54,700 | 25,516 | 1,144 | 56.372 | +| Factory 2 | 65,964 | 36,780 | 1,453 | 26.412 | +| Pool 2 | 65,753 | 36,569 | 1,939 | 74.706 | +| WETH 2 | 54,502 | 25,318 | 1,208 | 35.137 | + +Every trial reports 29,184 cached input tokens. Selected fact/reference/lifecycle +grades all pass: original/decoded identities, every inferred selector, offset, +dispatch, argument/mutability string including null, explicit unknowns, no runtime +execution, observed Evidence references, unchanged bytes and removed profiles. +Raw transcripts and grades remain private. Sampled peaks are +594,853,888–776,335,360 bytes; last-observed CPU is 6.181–7.034 seconds. Strict +overall grades remain unverified because final resource counters are unknown; +they are not relabeled as wholly passing resource-qualified runs. + +This calibrates the current complete path after compatible wiring, not a paired +model performance experiment. The next token experiment should answer the same +interface question through an explicitly selected precise view, retain full +bytecode/original Evidence under the existing lifetime, and preserve complete +defaults. Discover support before sending new options to an older server. Include +discovery, follow-up, failures and retries in the complete task cost. diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index 0bfd6da529..e763d07171 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -350,6 +350,38 @@ const inspectWasmTarget = async () => { }; }; +/** Identical complete interface question for pinned offline EVM carriers. */ +const inspectEvmTarget = async () => { + const info = await call("inspect_evm_interface", { + path: values.path, + encoding: "hex", + }); + const n = normalized(info); + if ( + n.evidence_kind !== "inferred" || + n.discovery_completeness !== "unknown" || + n.runtime_execution !== "not-performed" || + ["kind", "hardfork", "deployment_authenticity"].some( + (key) => n.bytecode[key] !== "unknown", + ) || + (artifact.decoded_sha256 !== undefined && + n.bytecode.sha256 !== artifact.decoded_sha256) || + (artifact.decoded_bytes !== undefined && + n.bytecode.bytes !== artifact.decoded_bytes) + ) + throw new Error("EVM interface identity or uncertainty changed"); + return { + artifact_sha256: targetSha(info), + artifact: n.artifact, + bytecode: n.bytecode, + functions: n.functions, + discovery_completeness: n.discovery_completeness, + runtime_execution: n.runtime_execution, + limitations: n.limitations, + evidence_ids: [info.evidence_id], + }; +}; + const workflows = { "obsidian.asar": async () => { if (values["javascript-question"] === "selected") { @@ -534,23 +566,9 @@ const workflows = { "sqlite.wasm": inspectWasmTarget, "vscode.tree-sitter.wasm": inspectWasmTarget, "vscode.tree-sitter-css.wasm": inspectWasmTarget, - "weth.hex": async () => { - const info = await call("inspect_evm_interface", { - path: values.path, - encoding: "hex", - }); - const n = normalized(info); - return { - artifact_sha256: targetSha(info), - artifact: n.artifact, - bytecode: n.bytecode, - functions: n.functions, - discovery_completeness: n.discovery_completeness, - runtime_execution: n.runtime_execution, - limitations: n.limitations, - evidence_ids: [info.evidence_id], - }; - }, + "weth.hex": inspectEvmTarget, + "uniswap-v3.factory.hex": inspectEvmTarget, + "uniswap-v3.pool.hex": inspectEvmTarget, ...Object.fromEntries( NATIVE_ARTIFACTS.map((artifact) => [ artifact, diff --git a/experiments/analysis-efficiency/questions/evm-uniswap.txt b/experiments/analysis-efficiency/questions/evm-uniswap.txt new file mode 100644 index 0000000000..a1db16f216 --- /dev/null +++ b/experiments/analysis-efficiency/questions/evm-uniswap.txt @@ -0,0 +1 @@ +Statically inspect the published Uniswap v3-core 1.0.1 compiler runtime template at {{artifact_path}} using only REA inspect_evm_interface with encoding hex. Report original carrier SHA-256/byte count, decoded bytecode SHA-256/byte count, every inferred function selector with bytecode_offset, dispatch, inferred_arguments and inferred_state_mutability exactly including null. Preserve evidence_kind, discovery_completeness, bytecode kind/hardfork/deployment_authenticity, runtime_execution, real Evidence IDs and limitations. The unchanged template came from the official published compiler artifact; constructor-dependent values and immutable-reference metadata are not established. Do not infer a live deployed instance, original source signatures or runtime behavior. No RPC, transactions, target execution, shell/file tools or other agents. Final JSON fields: artifact_sha256, artifact_bytes, bytecode_sha256, bytecode_bytes, functions, evidence_kind, discovery_completeness, bytecode_kind, hardfork, deployment_authenticity, runtime_execution, evidence_ids, limitations. diff --git a/experiments/analysis-efficiency/verify-evm-template.mjs b/experiments/analysis-efficiency/verify-evm-template.mjs new file mode 100644 index 0000000000..d119d14b4f --- /dev/null +++ b/experiments/analysis-efficiency/verify-evm-template.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: Object.fromEntries( + ["artifact", "artifact-json", "evidence", "keccak-module", "output"].map( + (key) => [key, { type: "string" }], + ), + ), +}); +assert.ok(values.artifact, "Select the exact corpus artifact ID"); +for (const key of ["artifact-json", "evidence", "keccak-module", "output"]) + assert.ok(isAbsolute(values[key] ?? ""), `${key} requires an absolute path`); +const corpus = JSON.parse( + await readFile(new URL("./corpus.json", import.meta.url), "utf8"), +); +const selected = corpus.artifacts.find(({ id }) => id === values.artifact); +assert.ok(selected?.provenance.compiler_artifact_sha256); +const source = await readFile(values["artifact-json"]); +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +assert.equal(hash(source), selected.provenance.compiler_artifact_sha256); +assert.equal(source.length, selected.provenance.compiler_artifact_bytes); +const artifact = JSON.parse(source); +const carrier = Buffer.from(artifact.deployedBytecode + "\n"); +assert.equal(hash(carrier), selected.sha256); +assert.equal(carrier.length, selected.bytes); +const { keccak_256 } = (await import(pathToFileURL(values["keccak-module"]))) + .default; +assert.equal( + typeof keccak_256, + "function", + "Supply js-sha3's Ethereum Keccak implementation", +); +const canonicalType = (input) => + input.type.startsWith("tuple") + ? `(${input.components.map(canonicalType).join(",")})${input.type.slice(5)}` + : input.type; +const selectors = artifact.abi + .filter((entry) => entry.type === "function") + .map((entry) => { + const signature = `${entry.name}(${entry.inputs.map(canonicalType).join(",")})`; + return { signature, selector: `0x${keccak_256(signature).slice(0, 8)}` }; + }); +const reply = JSON.parse(await readFile(values.evidence, "utf8")); +const evidence = reply.structuredContent ?? reply; +assert.equal(evidence.operation, "inspect_evm_interface"); +assert.equal(evidence.subject.digest.sha256, selected.sha256); +assert.equal(evidence.normalized_result.artifact.sha256, selected.sha256); +assert.equal( + evidence.normalized_result.bytecode.sha256, + selected.decoded_sha256, +); +assert.equal( + hash(Buffer.from(evidence.normalized_result.bytecode.hex, "hex")), + selected.decoded_sha256, +); +assert.equal(evidence.normalized_result.bytecode.bytes, selected.decoded_bytes); +assert.equal(evidence.normalized_result.evidence_kind, "inferred"); +assert.equal(evidence.normalized_result.discovery_completeness, "unknown"); +const observed = evidence.normalized_result.functions + .filter((entry) => entry.dispatch === "abi") + .map((entry) => entry.selector); +assert.deepEqual( + observed.toSorted(), + selectors.map((entry) => entry.selector).toSorted(), +); +await writeFile( + values.output, + JSON.stringify( + { + artifact: selected.id, + compiler_artifact_sha256: hash(source), + keccak_module_sha256: hash(await readFile(values["keccak-module"])), + evidence_id: evidence.evidence_id, + selector_set_matches_published_abi: true, + selectors, + scope: + "Independent published ABI-derived selector oracle; no deployed authenticity, inferred argument/mutability equivalence or runtime verification is established.", + }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, +); +process.stdout.write( + JSON.stringify({ + artifact: selected.id, + selector_count: selectors.length, + selector_set_matches_published_abi: true, + }) + "\n", +); diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index 735cfee1ec..883b4117b7 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -1,7 +1,6 @@ import { z } from "incur"; import { resolve } from "node:path"; -import { createEvmInterfaceService } from "../composition/evm.js"; -import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { evmInterfaceModule } from "../composition/evm.js"; import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; @@ -14,8 +13,8 @@ export const registerEvmCommands = ( logger: Logger, environment: Readonly, ): void => { - const service = createEvmInterfaceService(environment); - cli.command(CLI_COMMANDS.inspectEvmInterface, { + const service = evmInterfaceModule.create(environment); + cli.command(evmInterfaceModule.cliCommand, { description: "Infer interface candidates from explicit local EVM bytecode", args: z.object({ path: z.string().describe("Local bytecode carrier"), @@ -28,7 +27,7 @@ export const registerEvmCommands = ( try { return await logCliCommand( logger, - CLI_COMMANDS.inspectEvmInterface, + evmInterfaceModule.cliCommand, async () => { const result = await service.inspect( { path: resolve(args.path), encoding: args.encoding }, diff --git a/src/composition/evm.ts b/src/composition/evm.ts index 92c7e73fce..f51903a0f4 100644 --- a/src/composition/evm.ts +++ b/src/composition/evm.ts @@ -1,8 +1,21 @@ import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { INSPECT_EVM_INTERFACE_CONTRACT } from "../contracts/evm/evmToolContracts.js"; /** Compose offline interface analysis without import-time I/O or dependency acquisition. */ export const createEvmInterfaceService = ( environment: Readonly = process.env, ): EvmInterfaceService => new EvmInterfaceService(new EvmoleInterfaceProvider(environment)); + +/** + * Static binding of the existing operation, command and fresh service factory. + * The host owns injected service identity and its retryable cleanup lifetime. + * Construction performs no acquisition, target I/O or import-time execution. + */ +export const evmInterfaceModule = { + contract: INSPECT_EVM_INTERFACE_CONTRACT, + cliCommand: CLI_COMMANDS.inspectEvmInterface, + create: createEvmInterfaceService, +} as const; diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts index cb8be2736e..e8df79be32 100644 --- a/src/contracts/evm/evmToolContracts.ts +++ b/src/contracts/evm/evmToolContracts.ts @@ -7,20 +7,23 @@ import { toolContractMetadata } from "../toolEffects.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; /** Target-free EVM capabilities are independent of archive/Apple artifact providers. */ +export const INSPECT_EVM_INTERFACE_CONTRACT = { + name: "inspect_evm_interface", + ...toolContractMetadata("inspect_evm_interface"), + kind: "artifact-provider", + description: + "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.", + inputSchema: inspectEvmInterfaceInputSchema, + outputSchema: evidenceResultOf(evmInterfaceSchema), + examples: [ + { + title: "Inspect a local runtime bytecode hex carrier", + input: { path: "/artifacts/runtime.hex", encoding: "hex" }, + }, + ], +} as const satisfies ToolContract; + +/** Preserve the default catalog's exact operation and ordering. */ export const EVM_TOOL_CONTRACTS = [ - { - name: "inspect_evm_interface", - ...toolContractMetadata("inspect_evm_interface"), - kind: "artifact-provider", - description: - "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.", - inputSchema: inspectEvmInterfaceInputSchema, - outputSchema: evidenceResultOf(evmInterfaceSchema), - examples: [ - { - title: "Inspect a local runtime bytecode hex carrier", - input: { path: "/artifacts/runtime.hex", encoding: "hex" }, - }, - ], - }, + INSPECT_EVM_INTERFACE_CONTRACT, ] as const satisfies readonly ToolContract[]; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ec7a9f231a..3ff41d13cb 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -5,7 +5,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; -import { createEvmInterfaceService } from "../composition/evm.js"; +import { evmInterfaceModule } from "../composition/evm.js"; import { registerEvmTools } from "./registerEvmTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; @@ -242,7 +242,7 @@ export const createServer = ( const environment = snapshotEnvironment(options.environment ?? process.env); const delivery = selectToolResultDelivery(environment, options.delivery); const evmInterface = - options.evmInterface ?? createEvmInterfaceService(environment); + options.evmInterface ?? evmInterfaceModule.create(environment); const webSourceLocation = options.webSourceLocation ?? createWebSourceLocationService(environment); const webModuleTrace = @@ -372,6 +372,7 @@ export const createServer = ( toolContext, android, wasmArtifact, + evmInterface, jeb, firmwareAnalysis, ); @@ -449,6 +450,7 @@ const registerConfiguredAnalysisTools = ( }: ServerToolContext, android: AndroidAnalysisPort, wasmArtifact: Pick, + evmInterface: EvmInterfaceService, jeb: JebAnalysisPort, firmwareAnalysis: FirmwareAnalysisService, ): void => { @@ -505,7 +507,7 @@ const registerConfiguredAnalysisTools = ( ); registerEvmTools( server, - options.evmInterface ?? createEvmInterfaceService(environment), + evmInterface, toolLogger, recordEvidence, withAdmittedAnalysis, diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts index a2562329df..ff460672e9 100644 --- a/src/server/registerEvmTools.ts +++ b/src/server/registerEvmTools.ts @@ -2,7 +2,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; -import { toolContract } from "../contracts/toolContracts.js"; +import { evmInterfaceModule } from "../composition/evm.js"; import type { Logger } from "pino"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { createEvidenceToolHandler } from "./contractToolHandler.js"; @@ -16,7 +16,7 @@ export const registerEvmTools = ( recordEvidence?: EvidenceWriter["recordEvidence"], withAdmittedAnalysis?: WithAdmittedAnalysis, ): void => { - const contract = toolContract("inspect_evm_interface"); + const contract = evmInterfaceModule.contract; const handler = createEvidenceToolHandler( server, (input, options) => service.inspect(input, options), diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index d197c27429..a39fd53a8d 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,12 +1,25 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; -import { expect, it, onTestFinished } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js"; +import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../../../src/domain/analysisErrorCore.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; -import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { registerEvmTools } from "../../../src/server/registerEvmTools.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { + createDeferred, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { wasmArtifactExecution } from "../../fixtures/wasm/artifact.js"; import { evmInterfaceFixture, evmInterfaceExecution, @@ -54,6 +67,14 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit approval: true, }), ).toBe(false); + expect(() => registerEvmTools(server, service, silentLogger)).toThrow( + /inspect_evm_interface.*already registered/, + ); + expect( + (await client.listTools()).tools.filter( + (tool) => tool.name === "inspect_evm_interface", + ), + ).toHaveLength(1); const response = await client.callTool({ name: "inspect_evm_interface", arguments: { path: value.artifact.path, encoding: "hex" }, @@ -80,3 +101,151 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit ).isError, ).toBe(true); }); + +it.each(["unavailable", "incompatible-output"])( + "preserves a healthy WASM peer and explicit EVM failure: %s", + async (scenario) => { + const value = evmInterfaceFixture(); + const inspect = vi.fn(() => + Promise.resolve( + scenario === "unavailable" + ? err( + new AnalysisCapabilityUnavailableError( + "fixture-evm", + "inspect_evm_interface", + "selected engine unavailable", + ), + ) + : ok( + evmInterfaceExecution({ + ...value, + artifact: { ...value.artifact, encoding: "raw" }, + }), + ), + ), + ); + const harness = await createApplicationMcpHarness({ + serverOptions: { + evmInterface: new EvmInterfaceService({ inspect }), + wasmArtifact: new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: () => Promise.resolve(ok(wasmArtifactExecution())), + }), + }, + }); + onTestFinished(harness.close); + const invalid = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex", approval: true }, + }); + expect(invalid.isError).toBe(true); + expect(inspect).not.toHaveBeenCalled(); + const failure = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex" }, + }); + expect(parseMcpToolError(failure).error.code).toBe( + scenario === "unavailable" + ? "capability_unavailable" + : "unreadable_output", + ); + const peer = await harness.client.callTool({ + name: "inspect_wasm_artifact", + arguments: { path: "/artifacts/module.wasm" }, + }); + expect(peer.isError).not.toBe(true); + const parsed = toolContract("inspect_wasm_artifact").outputSchema.parse( + peer.structuredContent, + ); + const evidence = parseEvidence(parsed); + expect(harness.session.evidenceById(evidence.evidence_id)).toEqual( + evidence, + ); + await harness.client.ping(); + }, +); + +it("retains the injected EVM owner and retries failed cleanup on server shutdown", async () => { + const close = vi + .fn() + .mockRejectedValueOnce(new Error("retained EVM worker")) + .mockResolvedValue(undefined); + const service = new EvmInterfaceService({ + close, + inspect: () => Promise.resolve(ok(evmInterfaceExecution())), + }); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { evmInterface: service }, + ); + const client = new Client({ name: "evm-cleanup-retry", version: "1" }); + const [a, b] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(b); + await client.connect(a); + const response = await client.callTool({ + name: "inspect_evm_interface", + arguments: { path: "/artifacts/source-owned.hex", encoding: "hex" }, + }); + expect(response.isError).not.toBe(true); + await expect(server.close()).rejects.toThrow("REA session cleanup failed"); + await server.close(); + expect(close).toHaveBeenCalledTimes(2); + } finally { + await client.close(); + await server.close(); + await session.close(); + } +}); + +it("settles the admitted EVM operation after SDK cancellation and accepts a subsequent request", async () => { + const entered = createDeferred(); + const stopped = createDeferred(); + let invocations = 0; + const harness = await createApplicationMcpHarness({ + serverOptions: { + evmInterface: new EvmInterfaceService({ + inspect: async (_input, options) => { + if (invocations++ > 0) return ok(evmInterfaceExecution()); + const signal = options?.signal; + if (signal === undefined) + throw new Error("MCP request omitted cancellation signal"); + const aborted = new Promise((resolve) => { + if (signal.aborted) resolve(); + else + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + entered.resolve(); + await aborted; + stopped.resolve(); + return err(new AnalysisCancelledError("inspect_evm_interface")); + }, + }), + }, + }); + onTestFinished(harness.close); + const controller = new AbortController(); + const input = { + name: "inspect_evm_interface", + arguments: { path: "/artifacts/source-owned.hex", encoding: "hex" }, + }; + const cancellation = harness.client + .callTool(input, { signal: controller.signal }) + .then( + () => "completed", + (error: unknown) => error, + ); + await entered.promise; + controller.abort(new Error("SDK EVM cancellation verification")); + expect(await cancellation).toMatchObject({ + message: expect.stringContaining("SDK EVM cancellation verification"), + code: "REQUEST_TIMEOUT", + }); + await stopped.promise; + await harness.client.ping(); + expect((await harness.client.callTool(input)).isError).not.toBe(true); + expect(invocations).toBe(2); +}); From 11fe5a13f8aee87d0c0e036a9f497407e085fe48 Mon Sep 17 00:00:00 2001 From: Yijie Lu <115929844+1NormalGuy@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:37:47 +0800 Subject: [PATCH 23/93] fix(native): retain failed pwntools process and runtime cleanup (#1708) * fix(native): retain failed pwntools process and runtime cleanup Keep failed worker/debugger cleanup owners and their private files until verified process shutdown and successful directory removal. Retry cleanup before another inspection and expose shutdown through the shared services, CLI and MCP server. Serialize acquisition without delaying cancellation of queued callers, and preserve primary diagnostics when CLI cleanup fails. The original decoder reproduction left a live owned Node child with its runtime deleted and no retained cleanup owner; the regression now verifies retention, admission blocking, retries and eventual process/file cleanup. Validation: 10 lifecycle/public CLI/MCP tests, 20 service tests, test discovery 814/814, build:cached and full check including architecture guards. Actual POSIX process/filesystem boundaries used injected Node workers on macOS; real Linux pwntools/GDB provider parsing was not exercised. * test(native): verify retained recorded-crash cleanup lifecycle * fix(native): finalize diagnostic cleanup before publishing results * fix(native): retire decoder stages before reading runtime evidence --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../binaryDiagnostics/BinaryLayoutPort.ts | 5 + .../BinaryLayoutService.test.ts | 18 + .../binaryDiagnostics/BinaryLayoutService.ts | 28 +- .../binaryDiagnostics/RecordedCrashPort.ts | 5 + .../RecordedCrashService.test.ts | 18 + .../binaryDiagnostics/RecordedCrashService.ts | 28 +- src/cli/binaryDiagnosticsCommands.ts | 4 +- src/domain/analysisErrorBase.ts | 8 + src/domain/native/recordedCrash.ts | 11 + src/native/pwndbg/PwndbgCoreContext.ts | 40 +- src/native/pwntools/PwntoolsDecoder.ts | 324 ++++++++++++++-- .../pwntools/PwntoolsRecordedCrashProvider.ts | 54 ++- src/process/OwnedCommand.ts | 8 +- src/server/createServer.ts | 8 + .../pwntoolsLayoutProvider.test.ts | 32 +- .../recordedCrashProvider.test.ts | 156 +++++--- .../mcp/analysisSummaryDetailMcp.test.ts | 3 + tests/boundary/mcp/analysisViewMcp.test.ts | 3 + .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 8 + tests/boundary/mcp/recordedCrashMcp.test.ts | 8 + .../boundary/native/PwndbgCoreContext.test.ts | 3 + tests/fixtures/pwntoolsChangingArtifact.mjs | 40 ++ .../binaryDiagnosticsCliCleanup.test.ts | 111 ++++++ tests/process-global/pwntoolsCleanup.test.ts | 264 +++++++++++++ .../pwntoolsCliFinalization.test.ts | 308 +++++++++++++++ .../pwntoolsStageRetirement.test.ts | 356 ++++++++++++++++++ 26 files changed, 1716 insertions(+), 135 deletions(-) create mode 100644 tests/fixtures/pwntoolsChangingArtifact.mjs create mode 100644 tests/process-global/binaryDiagnosticsCliCleanup.test.ts create mode 100644 tests/process-global/pwntoolsCleanup.test.ts create mode 100644 tests/process-global/pwntoolsCliFinalization.test.ts create mode 100644 tests/process-global/pwntoolsStageRetirement.test.ts diff --git a/src/application/binaryDiagnostics/BinaryLayoutPort.ts b/src/application/binaryDiagnostics/BinaryLayoutPort.ts index a6936a60f0..55fe16c8ed 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutPort.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutPort.ts @@ -10,6 +10,11 @@ import type { ProviderIdentity } from "../AnalysisProvider.js"; /** Replaceable offline binary-layout provider; owns its parser and process protocol. */ export interface BinaryLayoutPort { readonly identity: ProviderIdentity; + inspectAndClose( + input: InspectBinaryLayoutInput, + options?: ExecutionOptions, + ): Promise>; + close?(): Promise; inspect( input: InspectBinaryLayoutInput, options?: ExecutionOptions, diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index e65da65f67..76a8d85518 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -17,6 +17,9 @@ it.each([ "rejects malformed selection before starting the provider: %j", async (input) => { const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -32,6 +35,9 @@ it.each([ it("records the decoded layout once in its Evidence", async () => { const layout = binaryLayoutFixture(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }); @@ -49,6 +55,9 @@ it("preserves a provider's actionable unsupported reason", async () => { "ELF core belongs to recorded-crash analysis.", ); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(err(failure)), }); @@ -58,6 +67,9 @@ it("preserves a provider's actionable unsupported reason", async () => { it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { controller.abort(); @@ -77,6 +89,9 @@ it("cancels before invocation and after an acquired observation", async () => { }, }); const untouched = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -112,6 +127,9 @@ it.each(["different-path", "invalid-digest", "outside-range"])( permissions: { read: true, write: false, execute: true }, }); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index bdb07da56a..68859b64ae 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -21,10 +21,36 @@ const OPERATION = "inspect_binary_layout"; export class BinaryLayoutService { constructor(readonly provider: BinaryLayoutPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Bind complete observations to the caller-selected artifact before creating Evidence. */ async inspect( rawInput: unknown, options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspect(input, options), + ); + } + + /** Complete the owned one-shot lifecycle before validating and publishing Evidence. */ + inspectAndClose( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspectAndClose(input, options), + ); + } + + async #inspect( + rawInput: unknown, + options: ExecutionOptions | undefined, + inspect: ( + input: Parameters[0], + ) => ReturnType, ): Promise> { if (options?.signal?.aborted) return err(new AnalysisCancelledError(OPERATION)); @@ -45,7 +71,7 @@ export class BinaryLayoutService { }, ]), ); - const inspected = await this.provider.inspect(input.data, options); + const inspected = await inspect(input.data); if (!inspected.ok) return inspected; const diagnostics = binaryLayoutSchema.shape.diagnostics.safeParse( inspected.value.diagnostics, diff --git a/src/application/binaryDiagnostics/RecordedCrashPort.ts b/src/application/binaryDiagnostics/RecordedCrashPort.ts index 3f9551c67d..aca32a0e42 100644 --- a/src/application/binaryDiagnostics/RecordedCrashPort.ts +++ b/src/application/binaryDiagnostics/RecordedCrashPort.ts @@ -12,6 +12,11 @@ import type { Result } from "../../domain/result.js"; /** Replaceable decoder of supplied recordings; historical PID is never live authority. */ export interface RecordedCrashPort { readonly identity: ProviderIdentity; + inspectAndClose( + input: InspectRecordedCrashInput, + options?: ExecutionOptions, + ): Promise>; + close?(): Promise; inspect( input: InspectRecordedCrashInput, options?: ExecutionOptions, diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index 0ab49ee1ba..cdf7cbd8f5 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -13,6 +13,9 @@ it("labels an envelope containing debugger-derived maps as derived", async () => const fixture = recordedCrashFixture(); fixture.debugger = recordedCrashDebuggerFixture(); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); @@ -36,6 +39,9 @@ it.each([ "rejects malformed selection before starting the provider: %j", async (input) => { const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -55,6 +61,9 @@ it("preserves a provider's actionable unsupported reason", async () => { "ELF core belongs to recorded-crash analysis.", ); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(err(failure)), }); @@ -64,6 +73,9 @@ it("preserves a provider's actionable unsupported reason", async () => { it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { controller.abort(); @@ -83,6 +95,9 @@ it("cancels before invocation and after an acquired observation", async () => { }, }); const untouched = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => { throw new Error("must not run"); @@ -117,6 +132,9 @@ it.each(["different-path", "invalid-digest", "outside-range"])( flags: "0x5", }); const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(fixture)), }); diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts index 5a4fff645d..df137f83e4 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.ts @@ -21,10 +21,36 @@ const OPERATION = "inspect_recorded_crash"; export class RecordedCrashService { constructor(readonly provider: RecordedCrashPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Bind complete observations to the caller-selected artifact before creating Evidence. */ async inspect( rawInput: unknown, options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspect(input, options), + ); + } + + /** Complete the owned one-shot lifecycle before validating and publishing Evidence. */ + inspectAndClose( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + return this.#inspect(rawInput, options, (input) => + this.provider.inspectAndClose(input, options), + ); + } + + async #inspect( + rawInput: unknown, + options: ExecutionOptions | undefined, + inspect: ( + input: Parameters[0], + ) => ReturnType, ): Promise> { if (options?.signal?.aborted) return err(new AnalysisCancelledError(OPERATION)); @@ -45,7 +71,7 @@ export class RecordedCrashService { }, ]), ); - const inspected = await this.provider.inspect(input.data, options); + const inspected = await inspect(input.data); if (!inspected.ok) return inspected; const diagnostics = recordedCrashSchema.shape.diagnostics.safeParse( inspected.value.diagnostics, diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 70c1199000..bddd16bed7 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -53,7 +53,7 @@ export const registerBinaryDiagnosticsCommands = ( run: ({ args }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => { - const result = await service.inspect( + const result = await service.inspectAndClose( { path: resolve(args.path) }, { signal }, ); @@ -75,7 +75,7 @@ export const registerBinaryDiagnosticsCommands = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => { - const result = await recorded.inspect( + const result = await recorded.inspectAndClose( { path: resolve(args.path), include_debugger_context: options.debuggerContext, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 389d4870ac..ff55627049 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -109,6 +109,11 @@ import type { AdbPackagePullPartialObservation, } from "./adb/adbDeviceAnalysis.js"; import type { NativeUiObservationResult } from "./native/nativeUiObservation.js"; +import type { BinaryLayout } from "./native/binaryLayout.js"; +import type { + RecordedCrash, + RecordedCrashPartialObservation, +} from "./native/recordedCrash.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -123,6 +128,9 @@ export type AnalysisPartialObservation = | WebScriptExportPartialObservation | JavaScriptRecoveryPartialObservation | Evidence + | BinaryLayout + | RecordedCrash + | RecordedCrashPartialObservation | AndroidPartialObservation | FirmwarePartialObservation | NativeUiObservationResult diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index 110bd1d914..d4b533838c 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -358,3 +358,14 @@ export type InspectRecordedCrashInput = z.output< typeof inspectRecordedCrashInputSchema >; export type RecordedCrash = z.output; + +/** Validated basic core evidence retained when requested debugger enrichment fails. */ +export type RecordedCrashPartialObservation = Omit< + RecordedCrash, + "debugger" +> & { + readonly debugger: { + readonly status: "unavailable"; + readonly requested: true; + }; +}; diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index 9c5f339e0f..0ef29cd9be 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -17,14 +17,14 @@ import { type RecordedCrash, } from "../../domain/native/recordedCrash.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; -import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { capturedPwntoolsOutput, pwntoolsDecoderFailure, } from "../pwntools/PwntoolsFailures.js"; import { PWNTOOLS_LIMITS } from "../pwntools/PwntoolsRelease.js"; import { readPwntoolsFailureEvidence } from "../pwntools/PwntoolsResourceLimits.js"; -import type { PwntoolsLauncher } from "../pwntools/PwntoolsDecoder.js"; +import type { PwntoolsCommand } from "../pwntools/PwntoolsDecoder.js"; const OPERATION = "inspect_recorded_crash"; const replySchema = z.discriminatedUnion("ok", [ @@ -48,14 +48,14 @@ export const inspectPwndbgCore = async ({ rootPath, diagnostics, options, - launcher, + runCommand, probePath, }: { readonly environment: Readonly; readonly rootPath: string; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; - readonly launcher?: PwntoolsLauncher; + readonly runCommand: PwntoolsCommand; readonly probePath?: ( path: string, kind: "file" | "directory", @@ -126,9 +126,23 @@ export const inspectPwndbgCore = async ({ { flag: "wx", mode: 0o600 }, ); let output: AnalysisCapturedOutput | undefined; + const failureContext = { + operation: OPERATION, + providerId: "pwndbg-core", + precedingOutput: diagnostics, + }; + const failure = (cause: unknown) => + pwntoolsDecoderFailure( + cause, + "debugger", + gdb, + environment.REA_PWNTOOLS_PYTHON ?? "", + {}, + failureContext, + ); try { options?.signal?.throwIfAborted(); - const execution = await runOwnedCommand( + const execution = await runCommand( { command: environment.REA_PWNTOOLS_PYTHON ?? "", arguments: [ @@ -155,14 +169,7 @@ export const inspectPwndbgCore = async ({ OMP_NUM_THREADS: "1", }, }, - { - timeoutMs: PWNTOOLS_LIMITS.timeoutMs, - diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, - }, - { - ...(options?.signal === undefined ? {} : { signal: options.signal }), - ...(launcher === undefined ? {} : { launcher }), - }, + failure, ); output = capturedPwntoolsOutput(execution); let reply: z.output; @@ -218,6 +225,7 @@ export const inspectPwndbgCore = async ({ ); return { ...reply.value, diagnostics: output }; } catch (cause: unknown) { + if (cause instanceof AnalysisError && cause.cleanupIncomplete) throw cause; if ( options?.signal?.aborted && (cause === options.signal.reason || @@ -232,11 +240,7 @@ export const inspectPwndbgCore = async ({ gdb, environment.REA_PWNTOOLS_PYTHON ?? "", await readPwntoolsFailureEvidence(cause, stage), - { - operation: OPERATION, - providerId: "pwndbg-core", - precedingOutput: diagnostics, - }, + failureContext, ); } }; diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 273f7d4b54..3a28edbfaf 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -11,9 +11,12 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import type { ProviderIdentity } from "../../application/AnalysisProvider.js"; -import type { AnalysisCapturedOutput } from "../../domain/analysisErrorBase.js"; +import type { + AnalysisCapturedOutput, + AnalysisPartialObservation, +} from "../../domain/analysisErrorBase.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -23,10 +26,17 @@ import { AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err, ok, type Result } from "../../domain/result.js"; import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import type { + ProviderProcessSupervisor, + OwnedProviderProcessSpawnOptions, + ProviderProcessSnapshot, +} from "../../process/ProviderProcess.js"; +import { PendingOperations } from "../../process/PendingOperations.js"; import { pwntoolsResourceLimitsSchema, readPwntoolsFailureEvidence, @@ -44,9 +54,15 @@ const HOST_REQUIREMENT = export type PwntoolsLauncher = NonNullable< Parameters[2] >["launcher"]; -interface DecoderReport { +/** Owner-bound execution; replies and failure files are stable only after this settles without cleanup uncertainty. */ +export type PwntoolsCommand = ( + spawn: OwnedProviderProcessSpawnOptions, + /** Project the original failure without reading files an unresolved worker may change. */ + failure: (cause: unknown) => AnalysisError, +) => Promise; +type DecoderReport = AnalysisPartialObservation & { readonly diagnostics: AnalysisCapturedOutput; -} +}; /** Adapter-selected semantics around a shared owned pwntools process boundary. */ export interface PwntoolsDecoderProfile< Input extends { readonly path: string }, @@ -64,6 +80,7 @@ export interface PwntoolsDecoderProfile< readonly rootPath: string; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; + readonly runCommand: PwntoolsCommand; }) => Report | Promise; } @@ -74,6 +91,22 @@ export class PwntoolsDecoder< Report extends DecoderReport, > { readonly identity: ProviderIdentity; + #tail: Promise = Promise.resolve(); + readonly #admissions = new PendingOperations< + symbol, + "admitted" | "cancelled" + >(); + #closing = false; + #closePromise: Promise | undefined; + #pendingCleanup: + | { + root: Pick | undefined; + process: ProviderProcessSupervisor | undefined; + failure: AnalysisError | undefined; + diagnostics: AnalysisCapturedOutput | undefined; + observation: AnalysisPartialObservation | undefined; + } + | undefined; constructor( readonly profile: PwntoolsDecoderProfile, readonly environment: Readonly, @@ -86,10 +119,61 @@ export class PwntoolsDecoder< } /** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */ - async inspect( + inspect( input: Input, options?: ExecutionOptions, ): Promise> { + return this.#enqueue(() => this.#inspect(input, options), options); + } + + /** Finalize an admitted inspection before publishing its outcome; rejected admission acquires nothing and does not close the provider. */ + inspectAndClose( + input: Input, + options?: ExecutionOptions, + ): Promise> { + return this.#enqueue(() => { + this.#closing = true; + return this.#inspect(input, options); + }, options); + } + + #enqueue( + inspect: () => Promise>, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return Promise.resolve( + err(new AnalysisCancelledError(this.profile.operation)), + ); + if (this.#closing) return Promise.resolve(err(this.#closedError())); + const key = Symbol(); + const admission = this.#admissions.wait(key, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + cancelledValue: () => "cancelled", + }); + const operation = this.#tail.then(() => { + this.#admissions.settle(key, "admitted"); + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(this.profile.operation)); + return this.#closing ? err(this.#closedError()) : inspect(); + }); + this.#tail = operation.then( + () => undefined, + () => undefined, + ); + return admission.then((state) => + state === "cancelled" + ? err(new AnalysisCancelledError(this.profile.operation)) + : operation, + ); + } + + async #inspect( + input: Input, + options?: ExecutionOptions, + ): Promise> { + const pending = await this.#retire(); + if (pending !== undefined) return err(pending); const OPERATION = this.profile.operation; const context = { operation: OPERATION, providerId: this.identity.id }; const replySchema = z.discriminatedUnion("ok", [ @@ -118,6 +202,15 @@ export class PwntoolsDecoder< let phase: "configuration" | "artifact-read" | "decoder" = "configuration"; const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? ""; let selectedPath = executablePath; + const failure = (cause: unknown) => + pwntoolsDecoderFailure( + cause, + phase, + selectedPath, + executablePath, + {}, + context, + ); try { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); if (process.platform !== "linux" || process.arch !== "x64") @@ -154,6 +247,13 @@ export class PwntoolsDecoder< ); phase = "decoder"; root = await this.createRuntime(); + this.#pendingCleanup = { + root, + process: undefined, + failure: undefined, + diagnostics: undefined, + observation: undefined, + }; const requestPath = join(root.path, "request.json"); const snapshotPath = join(root.path, "object.snapshot"); const replyPath = join(root.path, "reply.json"); @@ -167,7 +267,7 @@ export class PwntoolsDecoder< JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }), { flag: "wx", mode: 0o600 }, ); - const execution = await runOwnedCommand( + const execution = await this.#runCommand( { command: this.environment.REA_PWNTOOLS_PYTHON ?? "", arguments: [ @@ -188,14 +288,8 @@ export class PwntoolsDecoder< OMP_NUM_THREADS: "1", }, }, - { - timeoutMs: PWNTOOLS_LIMITS.timeoutMs, - diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, - }, - { - ...(options?.signal === undefined ? {} : { signal: options.signal }), - ...(this.launcher === undefined ? {} : { launcher: this.launcher }), - }, + options, + failure, ); const capturedOutput = capturedPwntoolsOutput(execution); let reply: z.output; @@ -277,9 +371,20 @@ export class PwntoolsDecoder< rootPath: root.path, diagnostics: capturedOutput, options, + runCommand: (spawn, classifyFailure) => + this.#runCommand(spawn, options, classifyFailure), }), ); } catch (cause: unknown) { + // The command owner has exhausted this operation's cleanup attempts. + // Keep its resources and do not consume files an unresolved worker may change. + if (cause instanceof AnalysisError && cause.cleanupIncomplete) { + if (this.#pendingCleanup !== undefined) { + this.#pendingCleanup.failure = cause; + this.#pendingCleanup.observation = cause.partialObservation; + } + return err(cause); + } const evidence = await readPwntoolsFailureEvidence(cause, root?.path); result = err( options?.signal?.aborted && @@ -296,37 +401,26 @@ export class PwntoolsDecoder< ), ); } - if (root !== undefined) { - try { - await root.close(); - } catch (cause: unknown) { - return err( - new ProviderCleanupError( - this.identity.id, - [root.path], - { - reason: cause instanceof Error ? cause.message : String(cause), - previous_error: result.ok - ? null - : projectAnalysisError(result.error), - ...(result.ok - ? { - captured_output: { - stdout: result.value.diagnostics.stdout, - stderr: result.value.diagnostics.stderr, - truncated: result.value.diagnostics.truncated, - }, - } - : {}), - }, - { operation: OPERATION }, - ), - ); - } + if (this.#pendingCleanup !== undefined) { + this.#pendingCleanup.failure = result.ok ? undefined : result.error; + this.#pendingCleanup.observation = result.ok + ? result.value + : result.error.partialObservation; + this.#pendingCleanup.diagnostics = result.ok + ? result.value.diagnostics + : result.error.capturedOutput; } + const cleanup = await this.#completeCleanup(() => this.#retire()); + if (cleanup !== undefined) + return err( + result.ok + ? cleanup + : analysisErrorWithCleanupFailure(result.error, cleanup, OPERATION), + ); return result.ok && options?.signal?.aborted ? err( new AnalysisCancelledError(OPERATION, { + partialObservation: result.value, capturedOutput: { stdout: result.value.diagnostics.stdout, stderr: result.value.diagnostics.stderr, @@ -336,4 +430,152 @@ export class PwntoolsDecoder< ) : result; } + + async #runCommand( + spawn: OwnedProviderProcessSpawnOptions, + options: ExecutionOptions | undefined, + failure: (cause: unknown) => AnalysisError, + ): Promise { + // A runtime is registered before either decoder stage may acquire a process. + const pending = this.#pendingCleanup; + if (pending === undefined) + throw new Error("Decoder command requires an owned runtime."); + let outcome: Result; + try { + outcome = ok( + await runOwnedCommand( + spawn, + { + timeoutMs: PWNTOOLS_LIMITS.timeoutMs, + diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, + }, + { + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + retainCleanup: (owner) => { + pending.process = owner; + }, + }, + ), + ); + } catch (cause: unknown) { + outcome = err(cause); + } + const cleanup = await this.#completeCleanup(() => this.#retireProcess()); + if (cleanup !== undefined) { + if (outcome.ok) throw cleanup; + // Until retirement succeeds, resource-limit markers remain unverified. + const primary = failure(outcome.error); + throw analysisErrorWithCleanupFailure( + primary, + cleanup, + this.profile.operation, + ); + } + if (!outcome.ok) throw outcome.error; + return outcome.value; + } + + /** Drain the running inspection, reject queued acquisition, and retry retained processes before deleting their files. */ + close(): Promise { + this.#closing = true; + this.#closePromise ??= this.#tail + .then(async () => { + const failure = await this.#retire(); + if (failure !== undefined) throw failure; + }) + .catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + #closedError(): AnalysisCapabilityUnavailableError { + return new AnalysisCapabilityUnavailableError( + this.identity.id, + this.profile.operation, + "Provider is closing; create a new inspection service.", + ); + } + + async #completeCleanup( + retire: () => Promise, + ): Promise { + const failure = await retire(); + return failure !== undefined && this.#closing ? retire() : failure; + } + + async #retireProcess(): Promise { + const pending = this.#pendingCleanup; + if (pending?.process === undefined) return undefined; + const stopped = await pending.process.stop(); + if (stopped.status === "incomplete") + return new ProviderCleanupError( + this.identity.id, + [ + pending.process.launch.ownership?.runId ?? "owned-decoder", + ...(pending.root === undefined ? [] : [pending.root.path]), + ], + { + reason: stopped.reason, + captured_output: { + ...(pending.diagnostics ?? + capturedPwntoolsOutput(pending.process.snapshot())), + }, + previous_error: + pending.failure === undefined + ? null + : projectAnalysisError(pending.failure), + }, + { + operation: this.profile.operation, + ...(pending.observation === undefined + ? {} + : { partialObservation: pending.observation }), + }, + ); + pending.process = undefined; + return undefined; + } + + async #retire(): Promise { + const failure = await this.#retireProcess(); + if (failure !== undefined) return failure; + const pending = this.#pendingCleanup; + if (pending === undefined) return undefined; + if (pending.root !== undefined) { + const root = pending.root; + try { + await root.close(); + pending.root = undefined; + } catch (cause: unknown) { + return new ProviderCleanupError( + this.identity.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + ...(pending.diagnostics === undefined + ? {} + : { captured_output: { ...pending.diagnostics } }), + previous_error: + pending.failure === undefined + ? null + : projectAnalysisError(pending.failure), + }, + { + operation: this.profile.operation, + cause, + ...(pending.observation === undefined + ? {} + : { partialObservation: pending.observation }), + }, + ); + } + } + this.#pendingCleanup = undefined; + return undefined; + } } diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts index e0d1ea8d2b..ffc10d13bc 100644 --- a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts +++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts @@ -1,4 +1,5 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { recordedCrashPayloadSchema, recordedCrashSchema, @@ -49,6 +50,7 @@ export class PwntoolsRecordedCrashProvider rootPath, diagnostics, options, + runCommand, }) => { const validate = (debuggerContext: RecordedCrash["debugger"]) => recordedCrashSchema.safeParse({ @@ -114,26 +116,38 @@ export class PwntoolsRecordedCrashProvider ); } if (!input.include_debugger_context) return value; - const context = await inspectPwndbgCore({ - environment, - rootPath, - diagnostics, - options, - ...(launcher === undefined ? {} : { launcher }), - }); - const enriched = validate(context); - if (!enriched.success) - throw new AnalysisOutputError( - "inspect_recorded_crash", - `Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`, - { - capturedOutput: - context.status === "available" - ? recordedCrashStageOutput(diagnostics, context.diagnostics) - : diagnostics, - }, - ); - return enriched.data; + try { + const context = await inspectPwndbgCore({ + environment, + rootPath, + diagnostics, + options, + runCommand, + }); + const enriched = validate(context); + if (!enriched.success) + throw new AnalysisOutputError( + "inspect_recorded_crash", + `Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`, + { + capturedOutput: + context.status === "available" + ? recordedCrashStageOutput( + diagnostics, + context.diagnostics, + ) + : diagnostics, + }, + ); + return enriched.data; + } catch (cause: unknown) { + if (cause instanceof AnalysisError) + cause.retainPartialObservation({ + ...value, + debugger: { status: "unavailable", requested: true }, + }); + throw cause; + } }, }, environment, diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index a6b500b1b7..02fb4defc0 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -45,6 +45,8 @@ export const runOwnedCommand = async ( }, options: { readonly signal?: AbortSignal; + /** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */ + readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void; readonly launcher?: ( input: OwnedProviderProcessSpawnOptions, ) => Promise; @@ -64,6 +66,7 @@ export const runOwnedCommand = async ( options.launcher ?? spawnOwnedProviderProcess, deadline, callerSignal, + options.retainCleanup, ); } finally { deadline.dispose(); @@ -82,6 +85,7 @@ const collectOwnedCommand = async ( ) => Promise, deadline: ProviderStartupDeadline, callerSignal?: AbortSignal, + retainCleanup?: (owner: ProviderProcessSupervisor) => void, ): Promise => { const signal = deadline.signal; let launched: SpawnedOwnedProviderProcess; @@ -190,7 +194,9 @@ const collectOwnedCommand = async ( } const stopped = await supervisor.stop(); const snapshot = supervisor.snapshot(); - if (stopped.status === "incomplete") + if (stopped.status === "incomplete" && retainCleanup !== undefined) + retainCleanup(supervisor); + else if (stopped.status === "incomplete") throw new OwnedCommandFailure( failure?.reason ?? "process", failure?.message ?? "Command cleanup failed.", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 3ff41d13cb..844cc5d72f 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -243,6 +243,10 @@ export const createServer = ( const delivery = selectToolResultDelivery(environment, options.delivery); const evmInterface = options.evmInterface ?? evmInterfaceModule.create(environment); + const binaryLayout = + options.binaryLayout ?? createBinaryLayoutService(environment); + const recordedCrash = + options.recordedCrash ?? createRecordedCrashService(environment); const webSourceLocation = options.webSourceLocation ?? createWebSourceLocationService(environment); const webModuleTrace = @@ -258,6 +262,8 @@ export const createServer = ( environment, delivery, evmInterface, + binaryLayout, + recordedCrash, webSourceLocation, webModuleTrace, javascriptRecovery, @@ -296,6 +302,8 @@ export const createServer = ( adbDevice.close(), flutter.close(), evmInterface.close(), + binaryLayout.close(), + recordedCrash.close(), webSourceLocation.close(), webModuleTrace.close(), javascriptRecovery.close?.() ?? Promise.resolve(), diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 71b7383ca3..a16496b560 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -11,6 +11,7 @@ import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/Pwntool import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { binaryLayoutFixture } from "../../fixtures/binaryDiagnostics/layout.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; @@ -94,6 +95,7 @@ it.runIf(!unsupportedHost)( async () => { const { path } = await fixture(); let ownedPath = ""; + let cleanupAllowed = false; const provider = new PwntoolsLayoutProvider( { REA_PWNTOOLS_PYTHON: process.execPath }, async (spawn) => { @@ -106,22 +108,37 @@ it.runIf(!unsupportedHost)( return { ...launched, cleanup: async () => { - await launched.cleanup?.(); + if (cleanupAllowed) + return cleanupOwnedProcessGroup(launched.ownership); throw new Error("injected post-cleanup reporting failure"); }, }; }, ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); const result = await provider.inspect({ path }); if (result.ok) throw new Error("Expected compounded lifecycle failure"); expect(result.error).toMatchObject({ cleanupIncomplete: true, diagnostics: { - reason: "injected post-cleanup reporting failure", - previous_error: { failure_kind: "output-limit" }, - captured_output: { truncated: true, stderr: "" }, + primary_error: { + code: "unreadable_output", + details: { captured_output: { truncated: true, stderr: "" } }, + }, + cleanup_error: { + code: "cleanup_incomplete", + details: { + diagnostics: { reason: "injected post-cleanup reporting failure" }, + }, + }, }, }); + await expect(access(ownedPath)).resolves.toBeUndefined(); + cleanupAllowed = true; + await provider.close(); await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); }, ); @@ -418,8 +435,11 @@ it.runIf(!unsupportedHost)( cleanupIncomplete: true, cleanupResources: [root], diagnostics: { - reason: "root cleanup failed", - previous_error: { category: "invalid_input" }, + primary_error: { category: "invalid_input" }, + cleanup_error: { + code: "cleanup_incomplete", + details: { diagnostics: { reason: "root cleanup failed" } }, + }, }, }, }); diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts index e10297ad3d..de422334b4 100644 --- a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts @@ -1,3 +1,4 @@ +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -9,7 +10,10 @@ import { } from "../../support/workspace/workspaceFixture.js"; import { PwntoolsRecordedCrashProvider } from "../../../src/native/pwntools/PwntoolsRecordedCrashProvider.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; -import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + spawnOwnedProviderProcess, + type SpawnedOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; import { @@ -27,6 +31,19 @@ const requestSchema = z.object({ }); const host = process.platform === "linux" && process.arch === "x64"; +const withCleanupGate = ( + launched: SpawnedOwnedProviderProcess, + cleanupAllowed: () => boolean, +): SpawnedOwnedProviderProcess => ({ + ...launched, + cleanup: async () => { + const result = await cleanupOwnedProcessGroup(launched.ownership); + if (!cleanupAllowed()) + throw new Error("source-owned post-cleanup reporting failure"); + return result; + }, +}); + it .runIf(host) .each([ @@ -132,6 +149,7 @@ it "invalid-context", "cancelled", "cleanup-failure", + "decoder-cleanup-failure", ] as const)( "owns optional context from the same snapshot: %s", async (scenario) => { @@ -139,6 +157,8 @@ it const controller = new AbortController(); let owned = ""; let snapshotPath = ""; + let cleanupAllowed = false; + let launches = 0; const venv = join(root, "venv"); const plugin = join(root, "gdbinit.py"); await mkdir(venv); @@ -155,6 +175,7 @@ it }), }, async (spawn) => { + launches++; const request = requestSchema.parse( JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")), ); @@ -177,11 +198,14 @@ it value: raw, }), ); - return spawnOwnedProviderProcess({ + const launched = await spawnOwnedProviderProcess({ ...spawn, command: process.execPath, arguments: ["-e", "process.stdout.write('basic stage diagnostic')"], }); + return scenario === "decoder-cleanup-failure" + ? withCleanupGate(launched, () => cleanupAllowed) + : launched; } expect(request.snapshot_path).toBe(snapshotPath); const context = recordedCrashDebuggerFixture(); @@ -207,17 +231,15 @@ it await waitForProviderProcessReady(launched.process); setImmediate(() => controller.abort()); } - if (scenario === "cleanup-failure") - return { - ...launched, - cleanup: async () => { - await cleanupOwnedProcessGroup(launched.ownership); - throw new Error("source-owned post-cleanup reporting failure"); - }, - }; - return launched; + return scenario === "cleanup-failure" + ? withCleanupGate(launched, () => cleanupAllowed) + : launched; }, ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); const result = await provider.inspect( { path, @@ -234,46 +256,90 @@ it }); } else { if (result.ok) throw new Error("Expected optional debugger failure"); - const projected = projectAnalysisError(result.error); - if (scenario === "unconfigured") - expect(projected).toMatchObject({ - details: { captured_output: { stdout: "basic stage diagnostic" } }, - }); - else if ( - scenario === "invalid-context" || - scenario === "cancelled" || - scenario === "cleanup-failure" - ) { - const serialized = JSON.stringify(projected); - expect(serialized).toContain("basic stage diagnostic"); - expect(serialized).toContain( - scenario === "cancelled" ? "ready" : "debugger stage diagnostic", - ); - expect(result.error._tag).toBe( - scenario === "invalid-context" - ? "AnalysisOutputError" - : scenario === "cancelled" - ? "AnalysisCancelledError" - : "ProviderAdapterError", - ); - if (scenario === "cleanup-failure") - expect(result.error).toBeInstanceOf(ProviderCleanupError); - } else - expect(projected).toMatchObject({ - category: "execution_failure", - details: { - diagnostics: { - preceding_output: { stdout: "basic stage diagnostic" }, - stderr: "debugger failed", - }, - }, - }); + expectOptionalContextFailure(scenario, result.error); } expect(await readFile(path)).toEqual(bytes); + if ( + scenario === "cleanup-failure" || + scenario === "decoder-cleanup-failure" + ) { + await expect(access(owned)).resolves.toBeUndefined(); + expect(await readFile(snapshotPath)).toEqual(bytes); + const refused = await provider.inspect({ + path, + include_debugger_context: true, + }); + if (refused.ok) + throw new Error("Unreleased ownership must block a launch"); + expect(refused.error.cleanupResources).toContain(owned); + expect(launches).toBe(scenario === "decoder-cleanup-failure" ? 1 : 2); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(access(owned)).resolves.toBeUndefined(); + cleanupAllowed = true; + await provider.close(); + } await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" }); }, ); +const expectOptionalContextFailure = ( + scenario: string, + error: AnalysisError, +) => { + const projected = projectAnalysisError(error); + if (scenario === "unconfigured") + expect(projected).toMatchObject({ + details: { captured_output: { stdout: "basic stage diagnostic" } }, + }); + else if (scenario === "decoder-cleanup-failure") + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { captured_output: { stdout: "basic stage diagnostic" } }, + }, + }); + else if ( + scenario === "invalid-context" || + scenario === "cancelled" || + scenario === "cleanup-failure" + ) { + const serialized = JSON.stringify(projected); + expect(serialized).toContain("basic stage diagnostic"); + expect(serialized).toContain( + scenario === "cancelled" ? "ready" : "debugger stage diagnostic", + ); + expect(error._tag).toBe( + scenario === "invalid-context" + ? "AnalysisOutputError" + : scenario === "cancelled" + ? "AnalysisCancelledError" + : "ProviderAdapterError", + ); + if (scenario === "cleanup-failure" || scenario === "invalid-context") { + if (scenario === "cleanup-failure") + expect(error).toBeInstanceOf(ProviderCleanupError); + expect(projected).toMatchObject({ + details: { + partial_observation: { + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + } + } else + expect(projected).toMatchObject({ + category: "execution_failure", + details: { + diagnostics: { + preceding_output: { stdout: "basic stage diagnostic" }, + stderr: "debugger failed", + }, + }, + }); +}; + const fixture = async (includeSignal = false, opaqueOwner = false) => { const workspace = await createTestWorkspace("rea-recorded-core-seam-"); onTestFinished(() => removeTestWorkspace(workspace.root)); diff --git a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts index 548619ec4a..925740b942 100644 --- a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts +++ b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts @@ -137,6 +137,9 @@ it("returns the summary of retained binary layout Evidence", async () => { const client = await connect( true, new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }), diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 62ec7d20b7..f232958297 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -179,6 +179,9 @@ it("discovers an oversized native dossier by exact metadata and reads its retain it("advertises exact schemas and projects one layout section from retained Evidence", async () => { const layout = analysisViewLayoutFixture(); const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(layout)), }); diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index d825d96de5..bea7c89dd1 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -14,9 +14,16 @@ import { it("advertises exact valid schemas and records inline layout without a binary target", async () => { const value = binaryLayoutFixture(); + let providerClosed = false; const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: BINARY_LAYOUT_TEST_PROVIDER, inspect: () => Promise.resolve(ok(value)), + close: async () => { + providerClosed = true; + }, }); const session = createTestBinarySession(() => { throw new Error("deep provider must not start"); @@ -29,6 +36,7 @@ it("advertises exact valid schemas and records inline layout without a binary ta onTestFinished(async () => { await client.close(); await server.close(); + expect(providerClosed).toBe(true); await session.close(); }); const [clientTransport, serverTransport] = diff --git a/tests/boundary/mcp/recordedCrashMcp.test.ts b/tests/boundary/mcp/recordedCrashMcp.test.ts index 3b809a296c..227ca5bd1d 100644 --- a/tests/boundary/mcp/recordedCrashMcp.test.ts +++ b/tests/boundary/mcp/recordedCrashMcp.test.ts @@ -14,9 +14,16 @@ import { it("advertises exact valid schemas and records inline recorded-core evidence without a binary target", async () => { const value = recordedCrashFixture(); + let providerClosed = false; const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, identity: RECORDED_CRASH_TEST_PROVIDER, inspect: () => Promise.resolve(ok(value)), + close: async () => { + providerClosed = true; + }, }); const session = createTestBinarySession(() => { throw new Error("deep provider must not start"); @@ -29,6 +36,7 @@ it("advertises exact valid schemas and records inline recorded-core evidence wit onTestFinished(async () => { await client.close(); await server.close(); + expect(providerClosed).toBe(true); await session.close(); }); const [clientTransport, serverTransport] = diff --git a/tests/boundary/native/PwndbgCoreContext.test.ts b/tests/boundary/native/PwndbgCoreContext.test.ts index 4eb042b6b3..e8f007a9bc 100644 --- a/tests/boundary/native/PwndbgCoreContext.test.ts +++ b/tests/boundary/native/PwndbgCoreContext.test.ts @@ -43,6 +43,9 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( diagnostics, options: { signal: controller.signal }, probePath, + runCommand: () => { + throw new Error("No process should exist before cancellation"); + }, }), ).rejects.toBeInstanceOf(AnalysisCancelledError); expect(probes).toBe(scenario === "pre-aborted" ? 0 : 1); diff --git a/tests/fixtures/pwntoolsChangingArtifact.mjs b/tests/fixtures/pwntoolsChangingArtifact.mjs new file mode 100644 index 0000000000..9dbc55fe6d --- /dev/null +++ b/tests/fixtures/pwntoolsChangingArtifact.mjs @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const [mode, path, initial, final, trigger, ready, done, exitCode] = + process.argv.slice(2); + +if (mode === "leader") { + writeFileSync(path, initial); + spawn( + process.execPath, + [ + fileURLToPath(import.meta.url), + "descendant", + path, + initial, + final, + trigger, + ready, + done, + ], + { stdio: "ignore" }, + ); + const timer = setInterval(() => { + if (!existsSync(ready)) return; + clearInterval(timer); + process.stdout.write("stage diagnostic", () => + process.exit(Number(exitCode)), + ); + }, 5); +} else { + writeFileSync(ready, String(process.pid)); + const timer = setInterval(() => { + if (!existsSync(trigger)) return; + writeFileSync(path, final); + writeFileSync(done, "changed"); + clearInterval(timer); + }, 5); + setInterval(() => undefined, 1000); +} diff --git a/tests/process-global/binaryDiagnosticsCliCleanup.test.ts b/tests/process-global/binaryDiagnosticsCliCleanup.test.ts new file mode 100644 index 0000000000..d23ef2a493 --- /dev/null +++ b/tests/process-global/binaryDiagnosticsCliCleanup.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { createCli } from "../../src/cli.js"; +import { CLI_COMMANDS } from "../../src/cliCommandNames.js"; +import * as composition from "../../src/composition/binaryDiagnostics.js"; +import { BinaryLayoutService } from "../../src/application/binaryDiagnostics/BinaryLayoutService.js"; +import { RecordedCrashService } from "../../src/application/binaryDiagnostics/RecordedCrashService.js"; +import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { err } from "../../src/domain/result.js"; +import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js"; + +const exitCode = process.exitCode; +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = exitCode; +}); + +it.each(["layout", "recorded"] as const)( + "projects the final owned outcome through the public CLI: %s", + async (scenario) => { + const primary = new AnalysisOutputError( + "inspect_binary_layout", + "fixture decoder output overflow", + { + capturedOutput: { + stdout: "partial original output", + stderr: "warning", + truncated: true, + }, + }, + ); + const cleanup = new ProviderCleanupError( + "fixture-decoder", + ["owned-process", "/retained/runtime"], + { reason: "fixture process inspection still denied" }, + ); + const inspectAndClose = vi.fn(async () => + err( + analysisErrorWithCleanupFailure( + primary, + cleanup, + "inspect_binary_layout", + ), + ), + ); + const identity = { + id: "fixture-decoder", + name: "Fixture decoder", + version: "1", + }; + if (scenario === "layout") + vi.spyOn(composition, "createBinaryLayoutService").mockReturnValue( + new BinaryLayoutService({ + identity, + inspect: () => { + throw new Error("CLI must request a terminal inspection"); + }, + inspectAndClose, + }), + ); + else + vi.spyOn(composition, "createRecordedCrashService").mockReturnValue( + new RecordedCrashService({ + identity, + inspect: () => { + throw new Error("CLI must request a terminal inspection"); + }, + inspectAndClose, + }), + ); + + const cli = createCli({}); + let stdout = ""; + await cli.serve( + [ + scenario === "layout" + ? CLI_COMMANDS.inspectBinaryLayout + : CLI_COMMANDS.inspectRecordedCrash, + "/selected/fixture.elf", + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const output: unknown = JSON.parse(stdout); + expect(output).toMatchObject({ + code: "cleanup_incomplete", + details: { + cleanup: "incomplete", + resources: ["owned-process", "/retained/runtime"], + captured_output: { + stdout: "partial original output", + stderr: "warning", + truncated: true, + }, + diagnostics: { + primary_error: { code: "unreadable_output" }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }); + expect(inspectAndClose).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(1); + }, +); diff --git a/tests/process-global/pwntoolsCleanup.test.ts b/tests/process-global/pwntoolsCleanup.test.ts new file mode 100644 index 0000000000..97e35ae66c --- /dev/null +++ b/tests/process-global/pwntoolsCleanup.test.ts @@ -0,0 +1,264 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, +} from "vitest"; + +import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { stopProviderProcessFixture } from "../fixtures/providerProcess.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const supportedHost = process.platform !== "win32"; + +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); + +// Only the decoder's availability gate is simulated. The module-owned process +// inspector was created and prepared for the real host before these overrides; +// every fixture launch, output stream, cleanup signal and filesystem is real. +beforeEach(() => { + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); + +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); +}); + +const fixture = async () => { + const workspace = await createTestWorkspace("rea-pwntools-cleanup-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = join(workspace.root, "selected.elf"); + await writeFile(path, "source-owned-seam-bytes"); + return { workspace, path }; +}; + +it.runIf(supportedHost)( + "retains live worker files, refuses another launch, and retries cleanup on close", + async () => { + const { workspace, path } = await fixture(); + let ownedPath = ""; + let pid: number | undefined; + let launches = 0; + let cleanupAllowed = false; + let releaseFixture: (() => Promise) | undefined; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + ownedPath = input.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + 'process.stdout.write("x".repeat(1048577)); setInterval(() => undefined, 1000)', + ], + }); + pid = launched.process.pid; + releaseFixture = () => stopProviderProcessFixture(launched.process); + return { + ...launched, + cleanup: () => + cleanupAllowed + ? cleanupOwnedProcessGroup(launched.ownership) + : Promise.resolve({ + cleaned: false as const, + reason: "fixture process inspection denied", + }), + }; + }, + () => + PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }), + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + await releaseFixture?.(); + }); + + const result = await provider.inspect({ path }); + if (result.ok) + throw new Error("Expected output overflow and failed cleanup"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + primary_error: { + code: "unreadable_output", + details: { captured_output: { truncated: true } }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }); + if (pid === undefined) + throw new Error("The fixture did not acquire a process"); + const workerPid = pid; + expect(() => process.kill(workerPid, 0)).not.toThrow(); + expect(await readFile(join(ownedPath, "object.snapshot"), "utf8")).toBe( + "source-owned-seam-bytes", + ); + const refused = await provider.inspect({ path }); + if (refused.ok) + throw new Error("Unreleased ownership must block another launch"); + expect(refused.error.cleanupResources).toContain(ownedPath); + expect(launches).toBe(1); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(access(ownedPath)).resolves.toBeUndefined(); + + cleanupAllowed = true; + await provider.close(); + expect(() => process.kill(workerPid, 0)).toThrow(); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "retains a failed root removal and retries it before acquiring another decoder", + async () => { + const { workspace, path } = await fixture(); + let releases = 0; + let launches = 0; + let removalAllowed = false; + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + return spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + 'process.stdout.write("retained diagnostic"); process.exitCode = 2', + ], + }); + }, + async () => ({ + path: root.path, + close: async () => { + releases++; + if (!removalAllowed) throw new Error("fixture root removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + await root.close(); + }); + const result = await provider.inspect({ path }); + if (result.ok) + throw new Error("Expected command and root-removal failures"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + primary_error: { + code: "execution_failure", + details: { diagnostics: { stdout: "retained diagnostic" } }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }); + const refused = await provider.inspect({ path }); + if (refused.ok) + throw new Error("Retained root cleanup must precede a new launch"); + expect(refused.error.cleanupResources).toEqual([root.path]); + expect(launches).toBe(1); + expect(releases).toBe(2); + removalAllowed = true; + await provider.close(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "cancels queued inspections promptly and drains the running inspection when closing", + async () => { + const { workspace, path } = await fixture(); + let release: (() => void) | undefined; + let entered: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + let launches = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + launches++; + entered?.(); + await gate; + return spawnOwnedProviderProcess({ + ...input, + arguments: ["-e", "process.exit(2)"], + }); + }, + () => + PrivateRuntimeRoot.create({ parent: workspace.root, prefix: "owned-" }), + ); + onTestFinished(async () => { + release?.(); + await provider.close(); + }); + const first = provider.inspect({ path }); + await started; + const controller = new AbortController(); + const cancelled = provider.inspect({ path }, { signal: controller.signal }); + controller.abort(); + await expect(cancelled).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + const queued = provider.inspect({ path }); + const closing = provider.close(); + const late = provider.inspect({ path }); + release?.(); + const [firstResult, queuedResult, lateResult] = await Promise.all([ + first, + queued, + late, + ]); + if (firstResult.ok) throw new Error("The fixture exits with failure"); + expect(firstResult.error).not.toMatchObject({ cleanupIncomplete: true }); + expect(queuedResult).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: false, + }, + }); + expect(lateResult).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + cleanupIncomplete: false, + }, + }); + await closing; + expect(launches).toBe(1); + }, +); diff --git a/tests/process-global/pwntoolsCliFinalization.test.ts b/tests/process-global/pwntoolsCliFinalization.test.ts new file mode 100644 index 0000000000..4ba9f2c129 --- /dev/null +++ b/tests/process-global/pwntoolsCliFinalization.test.ts @@ -0,0 +1,308 @@ +import { z } from "zod"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, + vi, +} from "vitest"; + +import { BinaryLayoutService } from "../../src/application/binaryDiagnostics/BinaryLayoutService.js"; +import { createCli } from "../../src/cli.js"; +import * as composition from "../../src/composition/binaryDiagnostics.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { PwntoolsLayoutProvider } from "../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js"; +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../src/native/pwntools/PwntoolsRelease.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { binaryLayoutFixture } from "../fixtures/binaryDiagnostics/layout.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const supportedHost = process.platform !== "win32"; +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const exitCode = process.exitCode; + +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); +beforeEach(() => { + // Prepare the real host inspector first; only decoder availability is simulated. + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); + process.exitCode = exitCode; + vi.restoreAllMocks(); +}); + +const fixture = async (outcome: "decoded" | "failed" = "decoded") => { + const workspace = await createTestWorkspace("rea-pwntools-finalization-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("selected.elf"); + await writeFile(path, Buffer.alloc(128)); + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const { + artifact: _artifact, + diagnostics: _diagnostics, + ...payload + } = binaryLayoutFixture(path); + const launch: PwntoolsLauncher = async (input) => { + const request = z + .object({ reply_path: z.string() }) + .parse( + JSON.parse(await readFile(join(root.path, "request.json"), "utf8")), + ); + return spawnOwnedProviderProcess({ + ...input, + arguments: [ + "-e", + outcome === "decoded" + ? 'require("node:fs").writeFileSync(process.argv[1], process.argv[2]); process.stdout.write("decoder output")' + : 'process.stdout.write("decoder output"); process.exitCode = 2', + request.reply_path, + JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value: payload, + }), + ], + }); + }; + return { path, root, launch }; +}; + +const runCli = async ( + provider: PwntoolsLayoutProvider, + path: string, +): Promise => { + vi.spyOn(composition, "createBinaryLayoutService").mockReturnValue( + new BinaryLayoutService(provider), + ); + let stdout = ""; + await createCli({}).serve(["inspect-binary-layout", path, "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }); + return JSON.parse(stdout); +}; + +it.runIf(supportedHost).each(["decoded", "failed"] as const)( + "returns the original %s outcome after a successful runtime-removal retry through the CLI", + async (outcome) => { + const { path, root, launch } = await fixture(outcome); + let removals = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (++removals === 1) throw new Error("transient removal failure"); + await root.close(); + }, + }), + ); + onTestFinished(() => provider.close()); + const output = await runCli(provider, path); + if (outcome === "decoded") { + expect(output).toMatchObject({ + normalized_result: { + artifact: { path }, + diagnostics: { stdout: "decoder output", truncated: false }, + }, + }); + expect(process.exitCode ?? 0).toBe(0); + } else { + expect(output).toMatchObject({ + code: "execution_failure", + details: { + diagnostics: { + exit_code: 2, + captured_output: { stdout: "decoder output" }, + }, + }, + }); + expect(output).not.toHaveProperty("details.cleanup"); + expect(process.exitCode).toBe(1); + } + expect(removals).toBe(2); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "recovers a completed worker reply after terminal process cleanup succeeds", + async () => { + const { path, root, launch } = await fixture(); + let stops = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + const launched = await launch(input); + return { + ...launched, + cleanup: () => + ++stops < 3 + ? Promise.resolve({ + cleaned: false as const, + reason: "transient process inspection failure", + }) + : cleanupOwnedProcessGroup(launched.ownership), + }; + }, + async () => root, + ); + onTestFinished(() => provider.close()); + expect(await runCli(provider, path)).toMatchObject({ + normalized_result: { artifact: { path } }, + }); + expect(process.exitCode ?? 0).toBe(0); + expect(stops).toBe(3); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "reports only the runtime still owned after process retirement", + async () => { + const { path, root, launch } = await fixture("failed"); + let stops = 0; + let removalAllowed = false; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (input) => { + const launched = await launch(input); + return { + ...launched, + cleanup: () => + ++stops === 1 + ? Promise.resolve({ + cleaned: false as const, + reason: "transient process inspection failure", + }) + : cleanupOwnedProcessGroup(launched.ownership), + }; + }, + async () => ({ + path: root.path, + close: async () => { + if (!removalAllowed) throw new Error("runtime removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + }); + expect(await runCli(provider, path)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: [root.path], + diagnostics: { + primary_error: { code: "execution_failure" }, + cleanup_error: { + code: "cleanup_incomplete", + details: { resources: [root.path] }, + }, + }, + }, + }); + expect(stops).toBe(2); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "preserves the validated report when runtime removal remains denied", + async () => { + const { path, root, launch } = await fixture(); + let removalAllowed = false; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (!removalAllowed) throw new Error("runtime removal denied"); + await root.close(); + }, + }), + ); + onTestFinished(async () => { + removalAllowed = true; + await provider.close(); + }); + expect(await runCli(provider, path)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: [root.path], + partial_observation: { + artifact: { path }, + diagnostics: { stdout: "decoder output", truncated: false }, + sections: binaryLayoutFixture(path).sections, + }, + }, + }); + expect(process.exitCode).toBe(1); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "preserves cancellation during successful final cleanup", + async () => { + const { path, root, launch } = await fixture(); + const controller = new AbortController(); + let removals = 0; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + launch, + async () => ({ + path: root.path, + close: async () => { + if (++removals === 1) { + controller.abort(); + throw new Error("transient removal failure"); + } + await root.close(); + }, + }), + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose( + { path }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Cancelled inspection must not publish success"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { + captured_output: { stdout: "decoder output", truncated: false }, + }, + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/tests/process-global/pwntoolsStageRetirement.test.ts b/tests/process-global/pwntoolsStageRetirement.test.ts new file mode 100644 index 0000000000..bcdaf858f2 --- /dev/null +++ b/tests/process-global/pwntoolsStageRetirement.test.ts @@ -0,0 +1,356 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + afterEach, + beforeAll, + beforeEach, + expect, + it, + onTestFinished, + vi, +} from "vitest"; +import { z } from "zod"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { PwntoolsRecordedCrashProvider } from "../../src/native/pwntools/PwntoolsRecordedCrashProvider.js"; +import type { PwntoolsLauncher } from "../../src/native/pwntools/PwntoolsDecoder.js"; +import { + PWNTOOLS_MEMORY_FAILURE_EXIT, + PWNTOOLS_PROVIDER_IDENTITY, +} from "../../src/native/pwntools/PwntoolsRelease.js"; +import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; +import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; +import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; +import * as artifacts from "../../src/artifacts/readStableArtifact.js"; +import { + recordedCrashFixture, + recordedCrashFixtureBytes, + recordedCrashDebuggerFixture, +} from "../fixtures/binaryDiagnostics/recordedCrash.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + +const supportedHost = process.platform !== "win32"; +const platform = Object.getOwnPropertyDescriptor(process, "platform"); +const architecture = Object.getOwnPropertyDescriptor(process, "arch"); +const worker = fileURLToPath( + new URL("../fixtures/pwntoolsChangingArtifact.mjs", import.meta.url), +); +beforeAll(() => + supportedHost ? prepareProcessOwnershipInspection() : undefined, +); +beforeEach(() => { + // Use the actual host inspector; simulate only the decoder availability gate. + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); +}); +afterEach(() => { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (architecture !== undefined) + Object.defineProperty(process, "arch", architecture); + vi.restoreAllMocks(); +}); + +const fixture = async () => { + const workspace = await createTestWorkspace("rea-stage-retirement-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("selected.core"); + const value = recordedCrashFixture(path); + await writeFile(path, recordedCrashFixtureBytes(value)); + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + prefix: "owned-", + }); + const venv = workspace.path("venv"); + const plugin = workspace.path("gdbinit.py"); + await mkdir(venv); + await writeFile(plugin, ""); + const environment = { + REA_PWNTOOLS_PYTHON: process.execPath, + REA_PWNDBG_GDB: process.execPath, + REA_PWNDBG_GDBINIT: plugin, + REA_PWNDBG_VENV_PATH: venv, + }; + const { + artifact: _artifact, + diagnostics: _diagnostics, + debugger: _debugger, + decoder_diagnostics: _decoderDiagnostics, + ...payload + } = value; + const reply = JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value: payload, + }); + const basic: PwntoolsLauncher = async (spawn) => { + await writeFile(join(root.path, "reply.json"), reply); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: ["-e", "process.stdout.write('basic diagnostic')"], + }); + }; + return { path, root, environment, basic }; +}; + +const changingWorker = async ( + spawn: Parameters>[0], + path: string, + contents: { initial: string; final: string; exitCode: number }, + cleanupAllowed: () => boolean = () => true, +) => { + const trigger = join(spawn.cwd ?? "", "change.trigger"); + const ready = join(spawn.cwd ?? "", "descendant.ready"); + const done = join(spawn.cwd ?? "", "change.done"); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + worker, + "leader", + path, + contents.initial, + contents.final, + trigger, + ready, + done, + String(contents.exitCode), + ], + }); + let stops = 0; + return { + ...launched, + cleanup: async () => { + if (++stops === 1 || !cleanupAllowed()) + return { + cleaned: false as const, + reason: "fixture inspection unavailable", + }; + const descendant = Number(await readFile(ready, "utf8")); + expect(() => process.kill(descendant, 0)).not.toThrow(); + await writeFile(trigger, "change now"); + await expect + .poll(() => + access(done).then( + () => true, + () => false, + ), + ) + .toBe(true); + return cleanupOwnedProcessGroup(launched.ownership); + }, + }; +}; + +it.runIf(supportedHost).each(["decoder", "debugger"] as const)( + "classifies %s failure markers only after surviving descendants stop", + async (stage) => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (stage === "debugger" && ++launches === 1) return basic(spawn); + const marker = join(spawn.cwd ?? "", "resource.failure"); + await writeFile( + join(spawn.cwd ?? "", "limits.json"), + JSON.stringify(recordedCrashDebuggerFixture().reported_limits), + ); + return changingWorker(spawn, marker, { + initial: "M", + final: "!", + exitCode: PWNTOOLS_MEMORY_FAILURE_EXIT, + }); + }, + async () => root, + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: stage === "debugger", + }); + if (result.ok) throw new Error("Expected the original command failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "execution_failure", + details: { + diagnostics: { + phase: stage, + resource_failure_marker: { resource: null }, + exit_code: PWNTOOLS_MEMORY_FAILURE_EXIT, + captured_output: { stdout: "stage diagnostic" }, + }, + }, + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost)( + "publishes debugger replies only after surviving descendants stop", + async () => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + const context = recordedCrashDebuggerFixture(); + const finalContext = { ...context, pwndbg_version: "after retirement" }; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (++launches === 1) return basic(spawn); + return changingWorker(spawn, join(spawn.cwd ?? "", "reply.json"), { + initial: JSON.stringify({ ok: true, value: context }), + final: JSON.stringify({ ok: true, value: finalContext }), + exitCode: 0, + }); + }, + async () => root, + ); + onTestFinished(() => provider.close()); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (!result.ok) throw result.error; + expect(result.value.debugger).toMatchObject({ + status: "available", + pwndbg_version: "after retirement", + }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(supportedHost).each([ + { stage: "decoder", outcome: "success" }, + { stage: "decoder", outcome: "failure" }, + { stage: "debugger", outcome: "success" }, + { stage: "debugger", outcome: "failure" }, +] as const)( + "does not read unresolved $stage files after command $outcome", + async ({ stage, outcome }) => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + let cleanupAllowed = false; + const read = vi.spyOn(artifacts, "readStableArtifact"); + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (stage === "debugger" && ++launches === 1) return basic(spawn); + return changingWorker( + spawn, + join(spawn.cwd ?? "", "resource.failure"), + { + initial: "M", + final: "!", + exitCode: outcome === "success" ? 0 : PWNTOOLS_MEMORY_FAILURE_EXIT, + }, + () => cleanupAllowed, + ); + }, + async () => root, + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (result.ok) throw new Error("Expected unresolved process ownership"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cleanup_incomplete"); + if (stage === "debugger") + expect(projected).toMatchObject({ + details: { + partial_observation: { + artifact: { path }, + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + if (outcome === "failure") + expect(projected).toMatchObject({ + details: { + diagnostics: { + primary_error: { code: "execution_failure" }, + }, + }, + }); + const stagePath = + stage === "debugger" ? join(root.path, "debugger") : root.path; + const paths = read.mock.calls.map(([selected]) => selected); + for (const file of ["reply.json", "resource.failure", "limits.json"]) + expect(paths).not.toContain(join(stagePath, file)); + const descendant = Number( + await readFile(join(stagePath, "descendant.ready"), "utf8"), + ); + expect(() => process.kill(descendant, 0)).not.toThrow(); + await expect(access(root.path)).resolves.toBeUndefined(); + }, +); + +it.runIf(supportedHost)( + "retains a live debugger after output overflow with basic evidence and retryable cleanup", + async () => { + const { path, root, environment, basic } = await fixture(); + let launches = 0; + let pid: number | undefined; + let cleanupAllowed = false; + const provider = new PwntoolsRecordedCrashProvider( + environment, + async (spawn) => { + if (++launches === 1) return basic(spawn); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + "-e", + 'process.stdout.write("x".repeat(1048577)); setInterval(() => undefined, 1000)', + ], + }); + pid = launched.process.pid; + return { + ...launched, + cleanup: () => + cleanupAllowed + ? cleanupOwnedProcessGroup(launched.ownership) + : Promise.resolve({ + cleaned: false as const, + reason: "fixture inspection unavailable", + }), + }; + }, + async () => root, + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspectAndClose({ + path, + include_debugger_context: true, + }); + if (result.ok) throw new Error("Expected retained output-limit failure"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "cleanup_incomplete", + details: { + captured_output: { truncated: true }, + diagnostics: { primary_error: { code: "unreadable_output" } }, + partial_observation: { + artifact: { path }, + diagnostics: { stdout: "basic diagnostic" }, + debugger: { status: "unavailable", requested: true }, + }, + }, + }); + if (pid === undefined) throw new Error("Expected a live owned debugger"); + const debuggerPid = pid; + expect(() => process.kill(debuggerPid, 0)).not.toThrow(); + cleanupAllowed = true; + await provider.close(); + expect(() => process.kill(debuggerPid, 0)).toThrow(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); From 2e45b4d7a5b3228fe8b283def6f047797561bee1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:45:11 +0800 Subject: [PATCH 24/93] fix(ci): stop aggregate jobs when verification is cancelled --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1514de8511..e882d07265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: static: name: Static checks needs: changes - if: ${{ always() }} + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - name: Require successful change classification @@ -212,7 +212,7 @@ jobs: test: name: Test (coverage + timing) needs: [changes, test-shard] - if: ${{ always() }} + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - name: Require successful change classification @@ -651,7 +651,7 @@ jobs: required: name: CI required - if: ${{ always() }} + if: ${{ !cancelled() }} needs: [ changes, From ec2d251c0ad8382e0665546be5ec8f5f1c175962 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Mon, 12 Oct 2026 22:56:12 +0500 Subject: [PATCH 25/93] fix(adb): count UTF-8 bytes in logcat total_bytes (#2212) * fix(adb): count UTF-8 bytes in logcat total_bytes read_adb_logcat assigned the decoded stdout string's UTF-16 code-unit length to total_bytes, undercounting non-ASCII dumps (a 37-byte CJK line reported 33). Measure the UTF-8 byte length instead, matching the field's byte-unit contract and the repo's Buffer.byteLength convention. Fixes #2175 * fix(adb): count raw captured bytes in logcat total_bytes Carry the pre-decode stdout byte length through runAdb so malformed UTF-8 (replaced by U+FFFD on decode) still reports the true captured byte count instead of the re-encoded replacement size. --- src/adb/AdbProvider.test.ts | 38 +++++++++++++++++++++++++++++++++++++ src/adb/AdbProvider.ts | 9 +++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 5f36917e4a..d1a1538480 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -508,6 +508,44 @@ it("reads a bounded logcat dump with the caller count and buffer", async () => { ]); }); +it("reports UTF-8 byte counts in total_bytes for non-ASCII logcat output", async () => { + const { binary } = await writeAdbStub( + shellStub({ + logcat: + "08-10 13:00:00.000 I/Tag( 1): 中文\n08-10 13:00:00.000 I/Tag( 1): 😀\n", + }), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "read_adb_logcat", + input: { serial: "emulator-5554", count: 500, buffer: "main" }, + }); + expect(result.lines).toEqual([ + "08-10 13:00:00.000 I/Tag( 1): 中文", + "08-10 13:00:00.000 I/Tag( 1): 😀", + ]); + // 30-byte prefix + 6-byte CJK + LF = 37; 30-byte prefix + 4-byte emoji + LF = 35. + expect(result.total_bytes).toBe(72); +}); + +it("counts raw captured bytes in total_bytes for malformed UTF-8 logcat output", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then + printf '%b' '08-10 13:00:00.000 I/Tag( 1): A\\377B\\n' + exit 0 +fi +printf 'unexpected arguments: %s\\n' "$@" +exit 1`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "read_adb_logcat", + input: { serial: "emulator-5554", count: 500, buffer: "main" }, + }); + // 30-byte prefix + "A" + one raw 0xff byte + "B" + LF = 34 captured bytes. + // Re-encoding the decoded U+FFFD replacement would report 36. + expect(result.total_bytes).toBe(34); +}); + it("projects package details from dumpsys package output", async () => { const { binary } = await writeAdbStub( shellStub({ diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index bb2f49ce03..7113d49bfa 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -232,12 +232,17 @@ const runAdbCapture = async ( /** Decode textual adb protocols after the shared byte capture and error boundary. */ const runAdb = async ( ...arguments_: Parameters -): Promise> => { +): Promise< + Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError> +> => { const captured = await runAdbCapture(...arguments_); if (!captured.ok) return captured; return ok({ stdout: captured.value.stdout.toString("utf8"), stderr: captured.value.stderr.toString("utf8"), + // Byte count of the captured output before UTF-8 decoding, so malformed + // sequences replaced by U+FFFD still report their true captured size. + stdoutBytes: captured.value.stdout.byteLength, }); }; @@ -815,7 +820,7 @@ export class AdbProvider { buffer: request.input.buffer, pid: request.input.pid ?? null, lines: bounded, - total_bytes: ran.value.stdout.length, + total_bytes: ran.value.stdoutBytes, coverage: bounded.length !== lines.length || bounded.length === request.input.count From f941968eb1860dd41efc2a5201b35cecca42301d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:17:14 +0800 Subject: [PATCH 26/93] fix(ci): allow cold Chrome startup in runtime fixtures Let the fixture lifecycle own CDP readiness with the same 60-second startup deadline as the broader browser verifier. Return the discovered port to both consumers and clean up failed startup before returning a lease. Validation: five startup/cleanup boundary tests, npm run check, and real Chrome 154 runtime and source-map CLI/MCP verifiers passed on macOS arm64. Hosted Linux verification follows the push. --- scripts/lib/browser-nested-source-map-e2e.mjs | 15 +------ scripts/lib/browser-runtime-e2e.mjs | 16 +------ .../lib/browser-runtime-fixture-lifecycle.mjs | 19 ++++++-- .../browser/runtimeFixtureCleanup.test.ts | 43 +++++++++++++++++++ 4 files changed, 61 insertions(+), 32 deletions(-) diff --git a/scripts/lib/browser-nested-source-map-e2e.mjs b/scripts/lib/browser-nested-source-map-e2e.mjs index 0325b69e48..ff2af88632 100644 --- a/scripts/lib/browser-nested-source-map-e2e.mjs +++ b/scripts/lib/browser-nested-source-map-e2e.mjs @@ -1,13 +1,11 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { TraceMap, eachMapping } from "@jridgewell/trace-mapping"; import { CdpBrowserProvider } from "../../dist/browser/CdpBrowserProvider.js"; import { CdpConnection } from "../../dist/browser/CdpConnection.js"; -import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js"; import { parseEvidence } from "../../dist/domain/evidence.js"; import { traceSourceMap } from "../../dist/javascript/sourceMaps/TraceSourceMap.js"; import { startBrowserSourceMapSite } from "../fixtures/browser-source-map-site.mjs"; @@ -63,7 +61,6 @@ async function verifyCase(executable, entrypoint, scenario) { let fixture; let connection; let primaryError; - let stderr = ""; let phase = "native-map-admission"; let browserVersion; let expected; @@ -90,17 +87,7 @@ async function verifyCase(executable, entrypoint, scenario) { phase = "owned-browser-startup"; fixture = await startRuntimeFixtureBrowser(executable, site.origin); - fixture.browser.stderr.on("data", (chunk) => { - if (stderr.length < 65536) stderr += chunk; - }); - const port = await waitForBrowserDevtoolsPort({ - child: fixture.browser, - executable, - activePortPath: join(fixture.profile, "DevToolsActivePort"), - stderr: () => stderr, - timeoutMs: 20_000, - }); - const endpoint = `http://127.0.0.1:${port}`; + const endpoint = `http://127.0.0.1:${fixture.port}`; const provider = new CdpBrowserProvider(); const targets = await provider.listTargets({ cdp_endpoint: endpoint, diff --git a/scripts/lib/browser-runtime-e2e.mjs b/scripts/lib/browser-runtime-e2e.mjs index c2ebbed021..c4cb4f09f6 100644 --- a/scripts/lib/browser-runtime-e2e.mjs +++ b/scripts/lib/browser-runtime-e2e.mjs @@ -3,14 +3,12 @@ import { spawn, execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; import { access } from "node:fs/promises"; -import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { CdpBrowserProvider } from "../../dist/browser/CdpBrowserProvider.js"; import { CdpConnection } from "../../dist/browser/CdpConnection.js"; -import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js"; import { parseEvidence } from "../../dist/domain/evidence.js"; import { webExecutionSchema } from "../../dist/domain/webExecution.js"; import { webEventListenersSchema } from "../../dist/domain/webEventListeners.js"; @@ -42,23 +40,11 @@ export async function verifyBrowserRuntime( const site = await startBrowserRuntimeSite(); let fixture; let primaryError; - let stderr = ""; let action; let client; try { fixture = await startRuntimeFixtureBrowser(executable, site.origin); - const { browser, profile } = fixture; - browser.stderr.on("data", (chunk) => { - if (stderr.length < 65536) stderr += chunk; - }); - const port = await waitForBrowserDevtoolsPort({ - child: browser, - executable, - activePortPath: join(profile, "DevToolsActivePort"), - stderr: () => stderr, - timeoutMs: 20_000, - }); - const endpoint = `http://127.0.0.1:${port}`; + const endpoint = `http://127.0.0.1:${fixture.port}`; const targets = await new CdpBrowserProvider().listTargets({ cdp_endpoint: endpoint, allowed_origins: [site.origin], diff --git a/scripts/lib/browser-runtime-fixture-lifecycle.mjs b/scripts/lib/browser-runtime-fixture-lifecycle.mjs index 24b88c793e..d39617f58c 100644 --- a/scripts/lib/browser-runtime-fixture-lifecycle.mjs +++ b/scripts/lib/browser-runtime-fixture-lifecycle.mjs @@ -8,8 +8,9 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js"; import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js"; import { CdpConnection } from "../../dist/browser/CdpConnection.js"; +import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js"; -/** Launch only the synthetic fixture browser in its own verified process group. */ +/** Launch the synthetic fixture browser and await CDP in its own verified process group. */ export async function startRuntimeFixtureBrowser(executable, origin) { const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-")); let owned; @@ -38,9 +39,21 @@ export async function startRuntimeFixtureBrowser(executable, origin) { expectedCommand: null, }); owned.process.stdout?.resume(); + let stderr = ""; + owned.process.stderr?.on("data", (chunk) => { + if (stderr.length < 65536) stderr += chunk; + }); + const port = await waitForBrowserDevtoolsPort({ + child: owned.process, + executable, + activePortPath: join(profile, "DevToolsActivePort"), + stderr: () => stderr, + // Match the broader real-browser verifier's allowance for cold Chrome + // startup on shared runners; early process failures still reject at once. + timeoutMs: 60_000, + }); return { - browser: owned.process, - profile, + port, close: async () => { await stopOwnedBrowser(owned, profile); await rm(profile, { diff --git a/tests/boundary/browser/runtimeFixtureCleanup.test.ts b/tests/boundary/browser/runtimeFixtureCleanup.test.ts index be6aca7cff..4aafa317f9 100644 --- a/tests/boundary/browser/runtimeFixtureCleanup.test.ts +++ b/tests/boundary/browser/runtimeFixtureCleanup.test.ts @@ -1,6 +1,49 @@ import { execFile } from "node:child_process"; +import { readdir } from "node:fs/promises"; import { promisify } from "node:util"; import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "rejects an exited fixture before returning a lease and removes its profile", + async () => { + const root = await createTestTempDirectory("rea-runtime-startup-failure-"); + const helper = new URL( + "../../../scripts/lib/browser-runtime-fixture-lifecycle.mjs", + import.meta.url, + ); + const script = ` + const { startRuntimeFixtureBrowser } = await import(${JSON.stringify(helper.href)}); + // Node rejects Chrome's arguments immediately, exercising actual process exit. + try { + const fixture = await startRuntimeFixtureBrowser(process.execPath, 'http://127.0.0.1'); + await fixture.close(); + throw new Error('Expected startup failure'); + } catch (error) { + if (error._tag !== 'BrowserStartupError') throw error; + console.log(JSON.stringify({ + failure: error.failure, + exitCode: error.exitCode, + stderr: error.stderr, + })); + } + `; + const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "-e", script], + { + env: { ...process.env, TMPDIR: root, TMP: root, TEMP: root }, + timeout: 10_000, + }, + ); + expect(JSON.parse(stdout)).toEqual({ + failure: "exited", + exitCode: expect.any(Number), + stderr: expect.stringContaining("bad option"), + }); + expect(await readdir(root)).toEqual([]); + }, +); it("attempts every fixture cleanup and retains the primary capture failure", async () => { const helper = new URL( From 47a51b08f4d79f808c1b74de79c3852aa853df53 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:56:16 +0530 Subject: [PATCH 27/93] fix(process): add an opt-in Swift module cache (#2252) * fix(process): add an opt-in Swift module cache Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> * fix(process): preserve caller-owned Swift module caches Fold the complementary implementation from #2260 into the earlier #2252 proposal, retaining the original contribution history. Co-authored-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> * fix(process): bypass xcrun lookup for explicit Swift toolchains --------- Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- docs/process-capture.md | 49 ++++ src/process/DarwinProcessRunTokenReader.ts | 76 +++-- src/process/ProcessOwnershipObservation.ts | 31 +- .../darwinProcessRunTokenReader.test.ts | 274 +++++++++++++++++- 4 files changed, 392 insertions(+), 38 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 35e818f311..cc163f7159 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -416,3 +416,52 @@ while its token became unavailable after this change; npm changes its title as well. A newly started unreadable process can prevent verified cleanup even after the selected command exits. REA preserves this uncertainty and leaves that process untouched. + +Reading those tokens requires compiling a small Swift helper with `xcrun swiftc`. +By default that compilation reuses Swift's shared module cache, while the +compiler's intermediate objects stay inside the per-run temporary root that REA +removes afterwards. Set `REA_DARWIN_MODULE_CACHE_PATH` to a writable directory +when a shared cache is unavailable, for example under an enforced write +boundary. REA creates the directory, passes it to the compiler as +`-module-cache-path`, and exports it as `CLANG_MODULE_CACHE_PATH` so the +Objective-C header modules the helper builds land there too. REA never removes +that directory: it outlives each run, so the first compilation into a new +directory pays a cold-cache build and later runs reuse it. An absent or empty +value keeps the shared default. + +`xcrun` also maintains a separate tool-lookup cache. Its `--no-cache` option +refreshes entries and does not guarantee a write-free lookup. To avoid that +lookup while compiling REA's helper, set `REA_DARWIN_SWIFTC_PATH` to the absolute +path of an installed `swiftc` and `SDKROOT` to the absolute path of a compatible +macOS SDK. REA invokes that compiler directly with `-sdk` and the selected SDK; +an unset or empty compiler setting keeps the normal `xcrun swiftc` path. A +missing or relative SDK/compiler path fails with an actionable prerequisite +error before compilation. An incompatible installation reports the compiler's +bounded diagnostics; REA does not repair or replace the toolchain. + +For example, select paths from your existing Xcode or Command Line Tools +installation **before** entering an enforced write boundary: + +```sh +export REA_DARWIN_SWIFTC_PATH="$(xcrun --find swiftc)" +export SDKROOT="$(xcrun --show-sdk-path)" +export REA_DARWIN_MODULE_CACHE_PATH="$PWD/rea-cache/modules" +export TMPDIR="$PWD/rea-cache/tmp" +mkdir -p "$TMPDIR" +rea analyze-javascript-application ./fixture --artifact-format directory +``` + +Keep the selected compiler and SDK paired. Permit REA's evidence/output paths +and its temporary directory as well as the caller-owned module cache. REA does +not create a sandbox or claim that these settings contain every write made by +arbitrary toolchains or analysis providers. They bypass REA's `xcrun` lookup +and select the helper's Swift/Clang cache without disabling ownership checks. +The caller-owned cache survives cancellation and cleanup; per-run compiler +intermediates and the compiled helper remain REA-owned temporary files. + +The boundary must also permit process-table inspection. On macOS, `/bin/ps` +is setuid and a blanket sandbox may reject its launch with `spawn EPERM`. +REA then preserves ownership uncertainty and can report `cleanup_incomplete`; +it does not disable verification to continue. A sandbox that explicitly allows +this read-only process inspector is a separate prerequisite from the compiler +and cache settings. diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 3fbb44a6ae..5aafcd7875 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; @@ -163,14 +163,19 @@ const compilerOutcomeDescription = ( const compilerInspectionError = ( cause: unknown, timeoutMs: number, + compiler: DarwinProcessReaderCompiler, ): DarwinProcessOwnershipInspectionError => { const outcome = compilerOutcome(cause, timeoutMs); const code = outcome?.code ?? "unknown"; const description = compilerOutcomeDescription(outcome, code); + const launcher = + compiler.kind === "xcrun" + ? "xcrun" + : "the selected Swift compiler (REA_DARWIN_SWIFTC_PATH)"; const prefix = code === "ENOENT" - ? "macOS process ownership inspection requires the Apple Swift compiler via xcrun" - : "macOS process ownership helper compilation failed via xcrun"; + ? `macOS process ownership inspection requires the Apple Swift compiler via ${launcher}` + : `macOS process ownership helper compilation failed via ${launcher}`; const stderr = boundedUtf8Text(outcome?.stderr ?? "", STDERR_EXCERPT_LIMIT); const diagnostics = stderr.text.trim(); const truncated = @@ -235,15 +240,35 @@ const waitForCompilation = async ( }); }; +/** Explicit tool selection; direct Swift compilation also needs its matching SDK. */ +export type DarwinProcessReaderCompiler = + | { readonly kind: "xcrun"; readonly executable: string } + | { + readonly kind: "swiftc"; + readonly executable: string; + readonly sdkRoot: string; + }; + /** Compile and retain the narrow Darwin sysctl reader for this REA process. */ export const createDarwinProcessRunTokenReader = ( options: { - readonly xcrun?: string; + readonly compiler?: DarwinProcessReaderCompiler; /** Deadline for the helper compilation; the default keeps verification fast. */ readonly compileTimeoutMs?: number; + /** + * Caller-owned directory holding the compiler's module cache. Unset keeps + * Swift's reusable default cache; a supplied directory outlives this + * reader because cleanup only removes the per-run helper root. + */ + readonly moduleCachePath?: string; } = {}, ) => { + const compiler = options.compiler ?? { + kind: "xcrun", + executable: "/usr/bin/xcrun", + }; const compilerTimeoutMs = options.compileTimeoutMs ?? COMPILER_TIMEOUT_MS; + const moduleCachePath = options.moduleCachePath ?? ""; let root: string | undefined; let executable: string | undefined; let compilation: ProcessReaderCompilation | undefined; @@ -266,6 +291,14 @@ export const createDarwinProcessRunTokenReader = ( const runCompilation = async (signal: AbortSignal): Promise => { let operationRoot: string | undefined; try { + if ( + compiler.kind === "swiftc" && + (!isAbsolute(compiler.executable) || !isAbsolute(compiler.sdkRoot)) + ) { + throw new DarwinProcessOwnershipInspectionError( + "Direct Darwin helper compilation requires absolute REA_DARWIN_SWIFTC_PATH and SDKROOT paths selecting a compatible installed Swift compiler and macOS SDK.", + ); + } operationRoot = await mkdtemp(join(tmpdir(), "rea-process-token-")); root = operationRoot; process.once("exit", cleanupAtExit); @@ -280,23 +313,30 @@ export const createDarwinProcessRunTokenReader = ( ...process.env, TMPDIR: compilerTemporary, }; - // Use Swift's reusable default cache for REA's internal helper compilation. - delete compilerEnvironment.CLANG_MODULE_CACHE_PATH; + const compilerArguments = + compiler.kind === "xcrun" ? ["swiftc"] : ["-sdk", compiler.sdkRoot]; + if (compiler.kind === "swiftc") + compilerEnvironment.SDKROOT = compiler.sdkRoot; + if (moduleCachePath === "") { + // Use Swift's reusable default cache for REA's internal helper compilation. + delete compilerEnvironment.CLANG_MODULE_CACHE_PATH; + } else { + await mkdir(moduleCachePath, { recursive: true }); + compilerArguments.push("-module-cache-path", moduleCachePath); + compilerEnvironment.CLANG_MODULE_CACHE_PATH = moduleCachePath; + } + compilerArguments.push(...sourceFiles, "-o", output); try { - await execFileOutput( - options.xcrun ?? "/usr/bin/xcrun", - ["swiftc", ...sourceFiles, "-o", output], - { - timeout: compilerTimeoutMs, - maxBuffer: 1024 * 1024, - signal, - stopSignal: SWIFTC_INTERRUPT, - env: compilerEnvironment, - }, - ); + await execFileOutput(compiler.executable, compilerArguments, { + timeout: compilerTimeoutMs, + maxBuffer: 1024 * 1024, + signal, + stopSignal: SWIFTC_INTERRUPT, + env: compilerEnvironment, + }); } catch (cause: unknown) { if (isExpectedAbort(cause, signal)) throw cause; - throw compilerInspectionError(cause, compilerTimeoutMs); + throw compilerInspectionError(cause, compilerTimeoutMs, compiler); } if (signal.aborted || closed) throw abortReason(signal); executable = output; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index f44feb960e..834335d57e 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -4,7 +4,10 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore. import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js"; import { execFileOutput } from "./ExecFileOutput.js"; -import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js"; +import { + createDarwinProcessRunTokenReader, + type DarwinProcessReaderCompiler, +} from "./DarwinProcessRunTokenReader.js"; import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; import type { OwnedProcessGroup, @@ -219,16 +222,32 @@ export const createSystemProcessOwnershipHost = ( hostEnvironment: NodeJS.ProcessEnv = process.env, options: { /** Override the compiler executable used by the Darwin native reader. */ - readonly darwinXcrun?: string; + readonly darwinCompiler?: DarwinProcessReaderCompiler; + /** Override the caller-owned module cache used compiling that helper. */ + readonly darwinModuleCachePath?: string; } = {}, ): ProcessOwnershipHost => { + const darwinModuleCachePath = + options.darwinModuleCachePath ?? + hostEnvironment.REA_DARWIN_MODULE_CACHE_PATH; + const swiftcPath = hostEnvironment.REA_DARWIN_SWIFTC_PATH; + const darwinCompiler = + options.darwinCompiler ?? + (swiftcPath === undefined || swiftcPath === "" + ? undefined + : { + kind: "swiftc" as const, + executable: swiftcPath, + sdkRoot: hostEnvironment.SDKROOT ?? "", + }); const darwinTokens = platform === "darwin" - ? createDarwinProcessRunTokenReader( - options.darwinXcrun === undefined + ? createDarwinProcessRunTokenReader({ + ...(darwinCompiler === undefined ? {} : { compiler: darwinCompiler }), + ...(darwinModuleCachePath === undefined ? {} - : { xcrun: options.darwinXcrun }, - ) + : { moduleCachePath: darwinModuleCachePath }), + }) : undefined; const listProcesses = async (signal?: AbortSignal) => { if (platform === "win32") return []; diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index caae8b2bec..8acafbd443 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, readFile, + readdir, rm, symlink, writeFile, @@ -156,7 +157,10 @@ type ElectronCaptureOutcome = it("reports an actionable missing Swift compiler without installing it", async () => { const reader = createDarwinProcessRunTokenReader({ - xcrun: join(tmpdir(), "rea-no-such-xcrun"), + compiler: { + kind: "xcrun", + executable: join(tmpdir(), "rea-no-such-xcrun"), + }, }); try { await expect( @@ -198,7 +202,9 @@ it.skipIf(process.platform === "win32")( '#!/usr/bin/env node\nprocess.stderr.write("module cache is not writable\\n" + "x".repeat(12000)); process.exitCode = 1;\n', ); await chmod(executable, 0o755); - const reader = createDarwinProcessRunTokenReader({ xcrun: executable }); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: executable }, + }); try { const failure = await preparationFailure(reader); expect(failure.compilerOutcome).toMatchObject({ @@ -248,7 +254,7 @@ it.skipIf(process.platform === "win32")( ); await chmod(executable, 0o755); const reader = createDarwinProcessRunTokenReader({ - xcrun: executable, + compiler: { kind: "xcrun", executable: executable }, // Headroom for the child interpreter's cold start under suite load. compileTimeoutMs: 3_000, }); @@ -280,7 +286,9 @@ it.skipIf(process.platform === "win32")( '#!/usr/bin/env node\nprocess.stderr.write("fatal: segmentation fault during type checking\\n");\nprocess.kill(process.pid, "SIGSEGV");\n', ); await chmod(executable, 0o755); - const reader = createDarwinProcessRunTokenReader({ xcrun: executable }); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: executable }, + }); try { await expect(preparationFailure(reader)).resolves.toMatchObject({ message: @@ -310,7 +318,9 @@ it.skipIf(process.platform === "win32")( `#!/usr/bin/env node\nprocess.stderr.write(${JSON.stringify(`${diagnostic}\n`)}); process.exitCode = 1;\n`, ); await chmod(executable, 0o755); - const reader = createDarwinProcessRunTokenReader({ xcrun: executable }); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: executable }, + }); try { const failure = await preparationFailure(reader); const outcome = failure.compilerOutcome; @@ -376,7 +386,7 @@ it.skipIf(process.platform === "win32")( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const preparation = reader.prepare(); try { @@ -390,6 +400,242 @@ it.skipIf(process.platform === "win32")( }, ); +const writeCacheRecordingCompiler = async (directory: string) => { + const executable = join(directory, "fake-cache-xcrun"); + const statePath = join(directory, "cache-compile-state.json"); + const source = `#!/usr/bin/env node +const fs = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const flagIndex = process.argv.indexOf("-module-cache-path"); +const cache = flagIndex === -1 ? undefined : process.argv[flagIndex + 1]; +fs.writeFileSync(${JSON.stringify(statePath)}, JSON.stringify({ + arguments: process.argv.slice(2), + moduleCacheEnvironment: process.env.CLANG_MODULE_CACHE_PATH ?? null, +})); +if (cache !== undefined) { + fs.mkdirSync(cache, { recursive: true }); + fs.writeFileSync(path.join(cache, "written-into-cache"), "kept"); +} +const output = process.argv[process.argv.indexOf("-o") + 1]; +fs.writeFileSync(output, "#!/bin/sh\\nexit 0\\n"); +fs.chmodSync(output, 0o755); +`; + await writeFile(executable, source); + await chmod(executable, 0o755); + return { executable, statePath }; +}; + +const readCacheCompileState = async (statePath: string) => + JSON.parse(await readFile(statePath, "utf8")) as { + arguments: string[]; + moduleCacheEnvironment: string | null; + }; + +const existsPath = async (target: string) => + access(target).then( + () => true, + () => false, + ); + +it.skipIf(process.platform === "win32")( + "forwards a caller-owned module cache and keeps it after the helper root is removed", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-caller-cache-test-", + ); + const cachePath = join(directory, "caller", " modules "); + const fakeCompiler = await writeCacheRecordingCompiler(directory); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, + moduleCachePath: cachePath, + }); + let executable: string; + try { + executable = await reader.prepare(); + } finally { + await reader.close(); + } + const state = await readCacheCompileState(fakeCompiler.statePath); + const flagIndex = state.arguments.indexOf("-module-cache-path"); + expect(state.arguments[0]).toBe("swiftc"); + expect(flagIndex).toBe(1); + expect(state.arguments[flagIndex + 1]).toBe(cachePath); + expect(state.moduleCacheEnvironment).toBe(cachePath); + expect(await existsPath(join(cachePath, "written-into-cache"))).toBe(true); + expect(await existsPath(executable)).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "selects the caller module cache through REA_DARWIN_MODULE_CACHE_PATH", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-host-cache-test-", + ); + const cachePath = join(directory, "from-environment", "modules"); + const fakeCompiler = await writeCacheRecordingCompiler(directory); + const host = createSystemProcessOwnershipHost( + "darwin", + { ...process.env, REA_DARWIN_MODULE_CACHE_PATH: cachePath }, + { + darwinCompiler: { kind: "xcrun", executable: fakeCompiler.executable }, + }, + ); + try { + await host.prepare?.(AbortSignal.timeout(15_000)); + const state = await readCacheCompileState(fakeCompiler.statePath); + const flagIndex = state.arguments.indexOf("-module-cache-path"); + expect(state.arguments[flagIndex + 1]).toBe(cachePath); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps the shared module cache for the system host without the setting", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-host-default-test-", + ); + vi.stubEnv("CLANG_MODULE_CACHE_PATH", join(directory, "ambient-cache")); + const hostEnvironment = { ...process.env }; + delete hostEnvironment.REA_DARWIN_MODULE_CACHE_PATH; + const fakeCompiler = await writeCacheRecordingCompiler(directory); + const host = createSystemProcessOwnershipHost("darwin", hostEnvironment, { + darwinCompiler: { kind: "xcrun", executable: fakeCompiler.executable }, + }); + try { + await host.prepare?.(AbortSignal.timeout(15_000)); + const state = await readCacheCompileState(fakeCompiler.statePath); + expect(state.arguments).not.toContain("-module-cache-path"); + expect(state.moduleCacheEnvironment).toBeNull(); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "bypasses xcrun for an explicitly selected Swift compiler and SDK", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-direct-compiler-", + ); + const fakeCompiler = await writeCacheRecordingCompiler(directory); + const sdkRoot = join(directory, "MacOSX.sdk"); + const cachePath = join(directory, "modules"); + const host = createSystemProcessOwnershipHost("darwin", { + ...process.env, + REA_DARWIN_SWIFTC_PATH: fakeCompiler.executable, + SDKROOT: sdkRoot, + REA_DARWIN_MODULE_CACHE_PATH: cachePath, + }); + try { + await host.prepare?.(AbortSignal.timeout(15_000)); + const state = await readCacheCompileState(fakeCompiler.statePath); + expect(state.arguments.slice(0, 2)).toEqual(["-sdk", sdkRoot]); + expect(state.arguments).not.toContain("swiftc"); + expect(state.arguments).toContain(cachePath); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "reports the missing SDK prerequisite before starting a direct compiler", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-direct-no-sdk-", + ); + const fakeCompiler = await writeCacheRecordingCompiler(directory); + const host = createSystemProcessOwnershipHost("darwin", { + ...process.env, + REA_DARWIN_SWIFTC_PATH: fakeCompiler.executable, + SDKROOT: "", + }); + try { + await expect( + host.prepare?.(AbortSignal.timeout(15_000)), + ).rejects.toMatchObject({ + name: "DarwinProcessOwnershipInspectionError", + message: expect.stringContaining("REA_DARWIN_SWIFTC_PATH and SDKROOT"), + }); + expect(await existsPath(fakeCompiler.statePath)).toBe(false); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "identifies a missing selected Swift compiler rather than xcrun", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-direct-missing-", + ); + const host = createSystemProcessOwnershipHost("darwin", { + ...process.env, + REA_DARWIN_SWIFTC_PATH: join(directory, "missing-swiftc"), + SDKROOT: join(directory, "MacOSX.sdk"), + }); + try { + await expect( + host.prepare?.(AbortSignal.timeout(15_000)), + ).rejects.toMatchObject({ + message: expect.stringContaining( + "selected Swift compiler (REA_DARWIN_SWIFTC_PATH)", + ), + compilerOutcome: expect.objectContaining({ + outcome: "spawn_failed", + code: "ENOENT", + }), + }); + } finally { + await host.close?.(); + } + }, +); + +it.skipIf(!onDarwin)( + "compiles the helper against the caller cache with the host compiler", + async () => { + const directory = await createTestTempDirectory( + "rea-process-token-real-cache-test-", + ); + const cachePath = join(directory, "caller", "modules"); + const { stdout: compilerPath } = await execFileAsync("/usr/bin/xcrun", [ + "--find", + "swiftc", + ]); + const { stdout: sdkRoot } = await execFileAsync("/usr/bin/xcrun", [ + "--show-sdk-path", + ]); + const host = createSystemProcessOwnershipHost("darwin", { + ...process.env, + REA_DARWIN_MODULE_CACHE_PATH: cachePath, + REA_DARWIN_SWIFTC_PATH: compilerPath.trim(), + SDKROOT: sdkRoot.trim(), + }); + try { + await host.prepare?.(AbortSignal.timeout(180_000)); + const entries = await readdir(cachePath); + expect( + entries.some( + (name) => + name.endsWith(".swiftmodule") || + name.endsWith(".pcm") || + name === "Modules", + ), + ).toBe(true); + } finally { + await host.close?.(); + } + expect(await existsPath(join(directory, "caller"))).toBe(true); + }, +); + it.skipIf(process.platform === "win32")( "rethrows compile cancellation, removes its temporary root, and permits retry", async () => { @@ -398,7 +644,7 @@ it.skipIf(process.platform === "win32")( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const controller = new AbortController(); const compilation = reader.prepare(controller.signal).then( @@ -483,7 +729,7 @@ it.skipIf(process.platform === "win32")( ); const compiler = await writeTemporaryObjectCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: compiler.executable, + compiler: { kind: "xcrun", executable: compiler.executable }, }); const controller = new AbortController(); const preparation = reader.prepare(controller.signal).then( @@ -532,7 +778,7 @@ it.skipIf(process.platform === "win32").each(["first", "second"] as const)( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const firstController = new AbortController(); const secondController = new AbortController(); @@ -590,7 +836,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const controller = new AbortController(); const preparation = reader.prepare(controller.signal).then( @@ -631,7 +877,7 @@ it.skipIf(process.platform === "win32")( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const preparation = reader.prepare().then( () => ({ state: "fulfilled" as const }), @@ -664,7 +910,7 @@ it.skipIf(process.platform === "win32")( ); const fakeCompiler = await writeBlockingCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const prepareSpy = vi .spyOn(systemProcessOwnershipHost, "prepare") @@ -716,7 +962,7 @@ it.skipIf(process.platform === "win32").each(["baseline", "tokens"] as const)( ); const fakeCompiler = await writeBlockingIdentityCompiler(directory); const host = createSystemProcessOwnershipHost("darwin", process.env, { - darwinXcrun: fakeCompiler.executable, + darwinCompiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const controller = new AbortController(); let helperRoot: string | undefined; @@ -775,7 +1021,7 @@ it.skipIf(process.platform === "win32")( ); const fakeCompiler = await writeBlockingIdentityCompiler(directory); const reader = createDarwinProcessRunTokenReader({ - xcrun: fakeCompiler.executable, + compiler: { kind: "xcrun", executable: fakeCompiler.executable }, }); const prepareSpy = vi .spyOn(systemProcessOwnershipHost, "prepare") From fd985dbc5b9435918732c0cc1825060af2a3a47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:26:34 +0800 Subject: [PATCH 28/93] fix(apple): join recognized keys from NIBArchive field maps (#2248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(apple): join recognized keys from NIBArchive field maps inspect-asset-catalog returned no resource-key matches for a decoded NIBArchive resource whose recognized image key is present in the archive. The NIBArchive projection groups an object's decoded fields under the node's nibValues attribute beside the class, label and ID it derives from the same archive, while the AppKit keyed-archive parser flattens the same fields onto the node. The catalog join read only the top level, so a recognized NSImageName on a decoded resource produced no row, and the empty-catalog limitation still claimed resource keys were reported as unmatched. Read an archive object's fields from both shapes, and give the namespace one owner by exporting NIB_VALUES_ATTRIBUTE from the projection that writes it. The recognized-field set stays in the catalog analysis, which owns that contract. Verified with 5 focused catalog tests, check:fast and verify:test-discovery on macOS arm64, and through the public CLI against the reported NIBArchive fixture. The keyed-archive control keeps its existing row, and a NIBArchive with only an unrecognized title still produces none. The ibtool-dependent native hierarchy lane requires full Xcode and was unavailable here. * fix(apple): normalize resource keys at the NIBArchive producer Fold the complementary implementation from #2256 into the earlier #2248 proposal, retaining the original contribution history. Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com> * fix(apple): restrict catalog joins to parsed resource nodes --------- Co-authored-by: 吴佳浩 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com> --- .../apple/AppleAssetCatalogAnalysis.test.ts | 92 ++++++++++++- .../apple/AppleAssetCatalogAnalysis.ts | 37 ++--- .../apple/InterfaceBuilderAnalysis.ts | 22 ++- src/domain/apple/appleAssetCatalog.ts | 23 ++++ .../assetCatalogResourceKeys.test.ts | 128 ++++++++++++++++++ 5 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 tests/acceptance/applications/assetCatalogResourceKeys.test.ts diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 13c3f9cdb6..4446fcd262 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -1,9 +1,11 @@ +import { createHash } from "node:crypto"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { encodeNibArchiveFixture } from "./NibArchive.fixture.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { analyzeAppleAssetCatalogs, @@ -22,6 +24,13 @@ describe("Apple asset catalog application workflow", () => { imageName: "Toolbar", title: "not-an-asset-key", image: 42, + nibValues: { + title: "not-an-asset-key", + imageName: { value: "not-a-scalar" }, + image: 42, + assetName: "", + nested: { NSImageName: "not-a-direct-field" }, + }, }, evidence: [ { @@ -34,8 +43,13 @@ describe("Apple asset catalog application workflow", () => { id: "ib:Main.nib:object:3", kind: "control", name: "Toolbar", - attributes: { title: "not-an-asset-key" }, - evidence: [], + attributes: { NSImageName: "Toolbar", imageName: "Toolbar" }, + evidence: [ + { + artifact_path: "Contents/Resources/Main.nib", + artifact_sha256: "d".repeat(64), + }, + ], }, ]), ).toEqual([ @@ -118,3 +132,77 @@ describe("Apple asset catalog application workflow", () => { ); }); }); + +it.each([1, 2])( + "joins NIBArchive keys against %i supplied assetutil renditions", + async (count) => { + const bundle = await createTestTempDirectory("rea-asset-nib-join-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + const archive = encodeNibArchiveFixture({ + classes: ["NSIBObjectData", "NSImageView", "NSImage", "NSString"], + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 1, values: { image: { ref: 2 } } }, + { classIndex: 2, values: { NSImageName: { ref: 3 } } }, + { classIndex: 3, values: { "NS.bytes": "Toolbar" } }, + ], + }); + await writeFile(join(resources, "Panel.nib"), archive); + await writeFile(join(resources, "Assets.car"), "inert catalog fixture"); + // Supplied metadata tests the post-engine join; it does not establish native catalog validity. + const result = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => + Promise.resolve( + JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + Name: "Toolbar", + RenditionName: `toolbar-${String(index)}.png`, + })), + ), + ), + }); + expect(result.resource_key_matches).toHaveLength(1); + expect(result.resource_key_matches[0]).toMatchObject({ + field: "NSImageName", + key: "Toolbar", + status: count === 1 ? "matched" : "ambiguous", + source_path: "Contents/Resources/Panel.nib", + source_archive_sha256: createHash("sha256").update(archive).digest("hex"), + renditions: Array.from({ length: count }, (_, index) => + expect.objectContaining({ + record_index: index, + rendition_name: `toolbar-${String(index)}.png`, + }), + ), + }); + }, +); + +it("keeps recognized image fields on NIBArchive controls out of resource joins", async () => { + const bundle = await createTestTempDirectory("rea-asset-nib-control-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Panel.nib"), + encodeNibArchiveFixture({ + classes: ["NSIBObjectData", "NSButton", "NSString"], + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 1, values: { NSImageName: { ref: 2 } } }, + { classIndex: 2, values: { "NS.bytes": "Toolbar" } }, + ], + }), + ); + await writeFile(join(resources, "Assets.car"), "inert catalog fixture"); + const result = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => Promise.resolve(JSON.stringify([{ Name: "Toolbar" }])), + }); + expect(result.resource_key_matches).toEqual([]); +}); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index c91dc53124..5fe1bced32 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -8,6 +8,7 @@ import { import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; import { appleAssetCatalogInputSchema, + appleResourceKeyFields, parseAppleAssetCatalogRecords, projectAppleAssetCatalogPage, } from "../../domain/apple/appleAssetCatalog.js"; @@ -16,17 +17,6 @@ import { execFileOutput } from "../../process/ExecFileOutput.js"; const ASSETUTIL = "/usr/bin/assetutil"; const MAX_CATALOG_BYTES = 64 * 1024 * 1024; const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024; -const RESOURCE_KEY_FIELDS = new Set([ - "image", - "imageName", - "image_name", - "resourceName", - "resource_name", - "assetName", - "asset_name", - "UIImageName", - "NSImageName", -]); /** Inspect compiled asset catalog metadata without extracting rendition bytes. */ export const analyzeAppleAssetCatalogs = async (input: { @@ -201,27 +191,22 @@ export const collectInterfaceBuilderResourceKeys = ( }[], ) => nodes.flatMap((node) => { + if (node.kind !== "resource") return []; const source = node.evidence.find( (item) => item.artifact_path !== null && item.artifact_sha256 !== null, ); const sourcePath = source?.artifact_path; const sourceArchiveSha256 = source?.artifact_sha256; if (sourcePath == null || sourceArchiveSha256 == null) return []; - return Object.entries(node.attributes).flatMap(([field, value]) => - RESOURCE_KEY_FIELDS.has(field) && - typeof value === "string" && - value.length > 0 - ? [ - { - sourceNodeId: node.id, - sourcePath, - sourceArchiveSha256, - field, - key: value, - }, - ] - : [], - ); + return appleResourceKeyFields(node.attributes).flatMap(([field, value]) => [ + { + sourceNodeId: node.id, + sourcePath, + sourceArchiveSha256, + field, + key: value, + }, + ]); }); const runAssetUtil = async ( diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 0f9506a669..89cf499e65 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { parseBinary } from "plist"; +import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { @@ -459,18 +460,27 @@ const projectNibArchive = ( break; } budget.reserveJsonStringOccurrence(name); + const nibValues = Object.fromEntries( + Object.entries(object.values).map(([key, value]) => { + budget.reserveJsonPropertyNameOccurrence(key); + return [key, dereference(value) ?? null]; + }), + ); + // Normalize only recognized scalar fields at the format owner. A keyed + // archive's authored nibValues dictionary must not gain this meaning. + const resourceFields = appleResourceKeyFields(nibValues); + for (const [field, value] of resourceFields) { + budget.reserveJsonPropertyNameOccurrence(field); + budget.reserveJsonStringOccurrence(value); + } return [ String(object.id), { customClass: className, label: name, objectID: String(object.id), - nibValues: Object.fromEntries( - Object.entries(object.values).map(([key, value]) => { - budget.reserveJsonPropertyNameOccurrence(key); - return [key, dereference(value) ?? null]; - }), - ), + nibValues, + ...Object.fromEntries(resourceFields), }, ]; }), diff --git a/src/domain/apple/appleAssetCatalog.ts b/src/domain/apple/appleAssetCatalog.ts index 2954ebda8e..4d04e14102 100644 --- a/src/domain/apple/appleAssetCatalog.ts +++ b/src/domain/apple/appleAssetCatalog.ts @@ -3,6 +3,29 @@ import { z } from "zod"; import { jsonValueSchema } from "../jsonValue.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; +const RESOURCE_KEY_FIELDS = new Set([ + "image", + "imageName", + "image_name", + "resourceName", + "resource_name", + "assetName", + "asset_name", + "UIImageName", + "NSImageName", +]); + +/** Recognized direct scalar resource fields; nested dictionaries are not inferred. */ +export const appleResourceKeyFields = ( + attributes: Readonly>, +): [string, string][] => + Object.entries(attributes).filter( + (entry): entry is [string, string] => + RESOURCE_KEY_FIELDS.has(entry[0]) && + typeof entry[1] === "string" && + entry[1].length > 0, + ); + /** Caller-controlled page bounds for compiled Apple asset metadata. */ export const appleAssetCatalogInputSchema = z.strictObject({ offset: z.number().int().min(0).default(0), diff --git a/tests/acceptance/applications/assetCatalogResourceKeys.test.ts b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts new file mode 100644 index 0000000000..5df6a1618f --- /dev/null +++ b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { buildBinary } from "plist"; + +import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; +import { appleAssetCatalogResultSchema } from "../../../src/domain/apple/appleAssetCatalog.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +// Exact rooted NIBArchive bytes from #2176; inert parser fixture, not ibtool output. +const reportedArchive = Buffer.from( + "TklCQXJjaGl2ZQEAAAAKAAAABAAAADIAAAAEAAAAPgAAAAQAAABgAAAABAAAAHwAAACAgIGBgYGCgoGDg4GGTlNSb290hWltYWdli05TSW1hZ2VOYW1liE5TLmJ5dGVzgAoBAAAAgQoCAAAAggoDAAAAgwiHVG9vbGJhco6ATlNJQk9iamVjdERhdGGLgE5TSW1hZ2VWaWV3h4BOU0ltYWdliIBOU1N0cmluZw==", + "base64", +); +const resourceArchive = (field: string) => + encodeNibArchiveFixture({ + classes: ["NSIBObjectData", "NSImageView", "NSImage", "NSString"], + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 1, values: { image: { ref: 2 } } }, + { classIndex: 2, values: { [field]: { ref: 3 } } }, + { classIndex: 3, values: { "NS.bytes": "Toolbar" } }, + ], + }); + +const keyedArchive = (nested: boolean) => + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $top: { root: { UID: 1 } }, + $objects: [ + "$null", + { + $class: { UID: 2 }, + ...(nested + ? { nibValues: { NSImageName: "Toolbar" } } + : { NSImageName: "Toolbar" }), + }, + { $classname: "NSImage", $classes: ["NSImage", "NSObject"] }, + ], + }); + +cliTest.skipIf(process.platform !== "darwin")( + "retains recognized NIBArchive resource keys and provenance through CLI and MCP without a catalog", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-asset-resource-keys-"); + const { call } = await connectLocalToolsMcp(); + for (const [name, field, archive, recognized] of [ + ["reported", "NSImageName", reportedArchive, true], + ["alias", "imageName", resourceArchive("imageName"), true], + ["title", "title", resourceArchive("title"), false], + ["keyed-direct", "NSImageName", keyedArchive(false), true], + ["keyed-nested", "NSImageName", keyedArchive(true), false], + ] as const) { + const app = join(root, `${name}.app`); + const contents = join(app, "Contents"); + await mkdir(join(contents, "Resources"), { recursive: true }); + await mkdir(join(contents, "MacOS")); + const mach = Buffer.alloc(32); + mach.writeUInt32LE(0xfeedfacf, 0); + mach.writeUInt32LE(0x0100000c, 4); + mach.writeUInt32LE(2, 12); + await writeFile(join(contents, "MacOS", "App"), mach); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableApp", + ); + await writeFile(join(contents, "Resources", "Panel.nib"), archive); + const decoded = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + }); + expect(decoded.exitCode, decoded.stderr).toBe(0); + const graph = interfaceBuilderAnalysisSchema.parse( + parseEvidence(decoded.json).normalized_result, + ).graph; + expect(graph.truncated).toBe(false); + const resource = graph.nodes.find( + (node) => + node.kind === "resource" && node.attributes.class_name === "NSImage", + ); + expect( + name === "keyed-direct" + ? resource?.attributes + : resource?.attributes.nibValues, + ).toMatchObject({ + [field]: "Toolbar", + }); + const inspected = await cli.run({ + arguments: ["inspect-asset-catalog", app, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const result = appleAssetCatalogResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + expect(result.catalogs).toEqual([]); + expect(result.resource_key_matches).toEqual( + !recognized + ? [] + : [ + { + source_node_id: resource?.id, + source_path: "Contents/Resources/Panel.nib", + source_archive_sha256: createHash("sha256") + .update(archive) + .digest("hex"), + field, + key: "Toolbar", + status: "unmatched", + renditions: [], + }, + ], + ); + const opened = await call("open_binary", { path: app }); + expect(opened.isError).not.toBe(true); + const mcp = await call("inspect_asset_catalog", {}); + expect(mcp.isError).not.toBe(true); + expect(parseEvidence(mcp.structuredContent).normalized_result).toEqual( + result, + ); + } + }, + 30_000, +); From 916ac97bf9f7b976982e438ceb856a9692576cb1 Mon Sep 17 00:00:00 2001 From: No BS AI <144746926+369Apps@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:29:07 -0700 Subject: [PATCH 29/93] fix(cli): retain browser and Inspector input correction details (#2230) Reuse the shared safe issue projection for all six CLI validation boundaries and cover malformed inputs, credential safety, and parser diagnostics. Fixes morluto/rea#2180 Developed with assistance from OpenAI Codex. --- src/cli/browserCommands.ts | 52 +++-- src/cliJavaScriptRuntimeCommands.ts | 33 +++- .../cli/browserInputDiagnostics.test.ts | 185 ++++++++++++++++++ 3 files changed, 247 insertions(+), 23 deletions(-) create mode 100644 tests/boundary/cli/browserInputDiagnostics.test.ts diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index 876b4565a1..aae6714d2a 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -14,7 +14,7 @@ import { } from "../domain/browserObservation.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../domain/browserSession.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { Logger } from "pino"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { @@ -48,13 +48,18 @@ const registerTargetList = ( run: ({ args, options }) => logCliCommand(logger, "list-browser-targets", async () => { const context = browserContext(); - const parsed = listBrowserTargetsInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, - }); + }; + const parsed = listBrowserTargetsInputSchema.safeParse(input); if (!parsed.success) return browserCliError( - new AnalysisInputError("list_browser_targets"), + analysisInputErrorFromIssues( + "list_browser_targets", + parsed.error.issues, + input, + ), ); const result = await listBrowserTargets(context.provider, parsed.data); return result.ok ? result.value : browserCliError(result.error); @@ -77,7 +82,7 @@ const registerPageInspection = ( run: ({ args, options }) => logCliCommand(logger, "inspect-web-page", async () => { const context = browserContext(); - const parsed = inspectWebPageInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, target_id: args.targetId, @@ -89,9 +94,16 @@ const registerPageInspection = ( include_script_sources: options.includeScriptSources, include_storage_keys: options.includeStorageKeys, include_storage_fingerprints: options.includeStorageFingerprints, - }); + }; + const parsed = inspectWebPageInputSchema.safeParse(input); if (!parsed.success) - return browserCliError(new AnalysisInputError("inspect_web_page")); + return browserCliError( + analysisInputErrorFromIssues( + "inspect_web_page", + parsed.error.issues, + input, + ), + ); const result = await inspectWebPage(context.provider, parsed.data); return result.ok ? result.value : browserCliError(result.error); }), @@ -120,16 +132,23 @@ const registerBundleAnalysis = ( run: ({ args, options }) => logCliCommand(logger, "analyze-web-bundle", async () => { const context = browserContext(); - const parsed = analyzeWebBundleInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, target_id: args.targetId, observation_ms: options.observationMs, include_script_sources: true, fetch_source_maps: options.fetchSourceMaps, - }); + }; + const parsed = analyzeWebBundleInputSchema.safeParse(input); if (!parsed.success) - return browserCliError(new AnalysisInputError("analyze_web_bundle")); + return browserCliError( + analysisInputErrorFromIssues( + "analyze_web_bundle", + parsed.error.issues, + input, + ), + ); const result = await analyzeWebBundle(context.provider, parsed.data); return result.ok ? result.value : browserCliError(result.error); }), @@ -153,14 +172,21 @@ const registerObservationSession = ( run: ({ args, options }) => logCliCommand(logger, "observe-web-session", async () => { const context = browserContext(); - const parsed = observeWebSessionInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, target_id: args.targetId, observation_ms: options.observationMs, - }); + }; + const parsed = observeWebSessionInputSchema.safeParse(input); if (!parsed.success) - return browserCliError(new AnalysisInputError("observe_web_session")); + return browserCliError( + analysisInputErrorFromIssues( + "observe_web_session", + parsed.error.issues, + input, + ), + ); const result = await observeWebSession(context.provider, parsed.data); return result.ok ? result.value : browserCliError(result.error); }), diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index 5ee6f23e71..23ff1e40ea 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -12,7 +12,7 @@ import { listJavaScriptRuntimeTargetsInputSchema, observeJavaScriptRuntimeInputSchema, } from "./domain/javascript/javascriptRuntimeObservation.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; +import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; @@ -47,11 +47,19 @@ export const registerJavaScriptRuntimeObservationCommands = ( CLI_COMMANDS.listJavaScriptRuntimeTargets, async () => { const context = runtimeContext(); - const parsed = listJavaScriptRuntimeTargetsInputSchema.safeParse({ + const input = { inspector_endpoint: args.endpoint, - }); + }; + const parsed = + listJavaScriptRuntimeTargetsInputSchema.safeParse(input); if (!parsed.success) - return inputError("list_javascript_runtime_targets"); + return cliError( + analysisInputErrorFromIssues( + "list_javascript_runtime_targets", + parsed.error.issues, + input, + ), + ); const result = await listJavaScriptRuntimeTargets( context.provider, parsed.data, @@ -73,13 +81,21 @@ export const registerJavaScriptRuntimeObservationCommands = ( run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.observeJavaScriptRuntime, async () => { const context = runtimeContext(); - const parsed = observeJavaScriptRuntimeInputSchema.safeParse({ + const input = { inspector_endpoint: args.endpoint, target_id: args.targetId, runtime_kind: options.runtimeKind, observation_ms: options.observationMs, - }); - if (!parsed.success) return inputError("observe_javascript_runtime"); + }; + const parsed = observeJavaScriptRuntimeInputSchema.safeParse(input); + if (!parsed.success) + return cliError( + analysisInputErrorFromIssues( + "observe_javascript_runtime", + parsed.error.issues, + input, + ), + ); const result = await observeJavaScriptRuntime( context.provider, parsed.data, @@ -93,9 +109,6 @@ const runtimeContext = () => ({ provider: createJavaScriptRuntimeObservationProvider(), }); -const inputError = (operation: string): JsonValue => - cliError(new AnalysisInputError(operation)); - const cliError = ( error: Parameters[0], ): JsonValue => ({ diff --git a/tests/boundary/cli/browserInputDiagnostics.test.ts b/tests/boundary/cli/browserInputDiagnostics.test.ts new file mode 100644 index 0000000000..e6b1646ae1 --- /dev/null +++ b/tests/boundary/cli/browserInputDiagnostics.test.ts @@ -0,0 +1,185 @@ +import { describe, expect } from "vitest"; + +import { workspaceCliTest as cliTest } from "../../support/cli/workspaceCliFixture.js"; + +const malformedEndpoint = "not a url"; +const browserEndpoint = "http://127.0.0.1:9222"; +const inspectorEndpoint = "http://127.0.0.1:9229"; + +const invalidInputs = [ + { + command: "list-browser-targets", + arguments: [malformedEndpoint], + operation: "list_browser_targets", + error: "Browser observation failed", + issue: { + path: ["cdp_endpoint"], + reason: "invalid_value", + message: "Invalid CDP endpoint URL", + }, + }, + { + command: "inspect-web-page", + arguments: [browserEndpoint, ""], + operation: "inspect_web_page", + error: "Browser observation failed", + issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + }, + { + command: "analyze-web-bundle", + arguments: [malformedEndpoint, "target"], + operation: "analyze_web_bundle", + error: "Browser observation failed", + issue: { + path: ["cdp_endpoint"], + reason: "invalid_value", + message: "Invalid CDP endpoint URL", + }, + }, + { + command: "observe-web-session", + arguments: [browserEndpoint, ""], + operation: "observe_web_session", + error: "Browser observation failed", + issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + }, + { + command: "list-javascript-runtime-targets", + arguments: [""], + operation: "list_javascript_runtime_targets", + error: "JavaScript runtime observation failed", + issue: { + path: ["inspector_endpoint"], + reason: "out_of_range", + minimum: 1, + }, + }, + { + command: "observe-javascript-runtime", + arguments: [inspectorEndpoint, ""], + operation: "observe_javascript_runtime", + error: "JavaScript runtime observation failed", + issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + }, +] as const; + +describe("browser and Inspector CLI input diagnostics", () => { + cliTest.for(invalidInputs)( + "$command retains the named contract's correction details", + async (input, { cli, workspace }) => { + const result = await cli.run({ + arguments: [input.command, ...input.arguments, "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + error: input.error, + code: "invalid_request", + category: "invalid_input", + message: + "Analysis input is invalid. Check the arguments and try again.", + retryable: true, + remediation: { action: "Correct the listed arguments and retry." }, + details: { + operation: input.operation, + issues: expect.arrayContaining([input.issue]), + }, + }); + expect(result.stdout).not.toContain(malformedEndpoint); + expect(result.stderr).not.toContain(malformedEndpoint); + }, + ); + + cliTest.for([ + ["list-browser-targets", "cdp_endpoint"], + ["list-javascript-runtime-targets", "inspector_endpoint"], + ] as const)( + "%s does not echo rejected endpoint credentials", + async ([command, field], { cli, workspace }) => { + const endpoint = + "http://rea-diagnostic-user:rea-diagnostic-secret@127.0.0.1:9222/"; + const result = await cli.run({ + arguments: [command, endpoint, "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "debug" }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: [field], + reason: "invalid_value", + message: + "CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1", + }, + ], + }, + }); + const output = result.stdout + result.stderr; + expect(output).not.toContain(endpoint); + expect(output).not.toContain("rea-diagnostic-user"); + expect(output).not.toContain("rea-diagnostic-secret"); + }, + ); + + cliTest( + "retains authored cross-field correction guidance", + async ({ cli, workspace }) => { + const result = await cli.run({ + arguments: [ + "inspect-web-page", + browserEndpoint, + "target", + "--include-storage-fingerprints", + "--json", + ], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_web_page", + issues: expect.arrayContaining([ + { + path: ["include_storage_fingerprints"], + reason: "invalid_value", + message: "Storage fingerprints require storage key capture", + }, + ]), + }, + }); + }, + ); + + cliTest( + "preserves the parser diagnostic for an omitted endpoint", + async ({ cli, workspace }) => { + const result = await cli.run({ + arguments: ["list-browser-targets", "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + { + path: "endpoint", + code: "invalid_type", + missing: true, + expected: "string", + }, + ], + }); + }, + ); +}); From aeee5074d124414239b6dd440dc79c26bcbd58a4 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:33:23 +0800 Subject: [PATCH 30/93] feat(sqlite): inspect offline snapshots with selective evidence (#2271) Add offline SQLite database/WAL inspection through the shared CLI/MCP. Use compact inventory, selected-table schema, exact ordered columns and bounded row samples while preserving source identity and lossless values. Retain completed Evidence through late cancellation and private-root cleanup failures, bound concurrent inspections, and recheck journal validity after acquisition. Reuse prepared CI runtime artifacts across supported Node lines. Co-authored-by: Y0UW1SH <29498139+Y0UW1SH@users.noreply.github.com> Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- .../skills/reverse-engineer-anything/SKILL.md | 9 + .../references/native-and-artifacts.md | 15 + .github/workflows/ci.yml | 6 + .github/workflows/sqlite-runtime.yml | 37 ++ README.md | 1 + docs/.vitepress/config.ts | 1 + docs/cli.md | 8 + docs/sqlite-databases.md | 144 +++++ package.json | 2 + scripts/ci/plan.mjs | 1 + scripts/ci/required.mjs | 2 +- scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 2 + scripts/lib/module-boundaries.mjs | 1 + src/application/CapabilityInventory.ts | 10 + src/application/DirectAnalysisStatus.ts | 12 +- src/application/binary/BinarySessionStatus.ts | 10 +- .../sqlite/SqliteDatabaseCapability.test.ts | 31 + .../sqlite/SqliteDatabaseCapability.ts | 33 + src/application/sqlite/SqliteDatabasePort.ts | 17 + .../sqlite/SqliteDatabaseService.test.ts | 147 +++++ .../sqlite/SqliteDatabaseService.ts | 121 ++++ src/artifacts/readStableArtifact.ts | 23 +- src/cli.ts | 2 + src/cli/sqliteCommands.ts | 111 ++++ src/cliCommandNames.ts | 1 + src/composition/sqlite.ts | 8 + src/contracts/sqlite/sqliteToolContracts.ts | 42 ++ src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.test.ts | 2 + src/contracts/toolEffects.ts | 5 + src/domain/analysisErrorCore.ts | 11 +- src/domain/analysisErrorPresentation.ts | 16 +- src/domain/analysisErrorProjection.test.ts | 34 + src/domain/analysisErrorProjection.ts | 6 +- src/domain/sqlite/sqliteDatabase.ts | 235 +++++++ .../JavaScriptAnalysisWorkerProtocol.ts | 3 +- src/main/transport.ts | 53 +- src/server/contractToolHandler.ts | 13 +- src/server/createServer.ts | 15 + src/server/registerSqliteTools.ts | 31 + src/sqlite/SqliteDatabaseFailures.ts | 165 +++++ src/sqlite/SqliteDatabaseInspection.ts | 514 +++++++++++++++ src/sqlite/SqliteDatabaseLimits.ts | 23 + src/sqlite/SqliteDatabaseProvider.ts | 410 ++++++++++++ src/sqlite/SqliteDatabaseSnapshot.ts | 270 ++++++++ src/sqlite/SqliteDatabaseWorker.ts | 57 ++ src/sqlite/SqliteDatabaseWorkerExecution.ts | 169 +++++ tests/acceptance/sqliteDatabaseCli.test.ts | 597 ++++++++++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 4 + .../filesystem/ciRequiredLanes.test.ts | 104 +++ tests/boundary/mcp/serverLifecycleMcp.test.ts | 323 ++++++++++ .../sqlite/sqliteDatabaseCleanup.test.ts | 275 ++++++++ .../sqliteDatabaseDescriptorCleanup.test.ts | 378 +++++++++++ .../sqlite/sqliteDatabaseInspection.test.ts | 328 ++++++++++ .../sqlite/sqliteDatabaseProvider.test.ts | 595 +++++++++++++++++ .../sqlite/sqliteDatabaseSnapshot.test.ts | 264 ++++++++ tests/fixtures/android/jadx-mcp.mjs | 3 + tests/fixtures/sqlite/database.ts | 74 +++ 59 files changed, 5757 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/sqlite-runtime.yml create mode 100644 docs/sqlite-databases.md create mode 100644 src/application/sqlite/SqliteDatabaseCapability.test.ts create mode 100644 src/application/sqlite/SqliteDatabaseCapability.ts create mode 100644 src/application/sqlite/SqliteDatabasePort.ts create mode 100644 src/application/sqlite/SqliteDatabaseService.test.ts create mode 100644 src/application/sqlite/SqliteDatabaseService.ts create mode 100644 src/cli/sqliteCommands.ts create mode 100644 src/composition/sqlite.ts create mode 100644 src/contracts/sqlite/sqliteToolContracts.ts create mode 100644 src/domain/sqlite/sqliteDatabase.ts create mode 100644 src/server/registerSqliteTools.ts create mode 100644 src/sqlite/SqliteDatabaseFailures.ts create mode 100644 src/sqlite/SqliteDatabaseInspection.ts create mode 100644 src/sqlite/SqliteDatabaseLimits.ts create mode 100644 src/sqlite/SqliteDatabaseProvider.ts create mode 100644 src/sqlite/SqliteDatabaseSnapshot.ts create mode 100644 src/sqlite/SqliteDatabaseWorker.ts create mode 100644 src/sqlite/SqliteDatabaseWorkerExecution.ts create mode 100644 tests/acceptance/sqliteDatabaseCli.test.ts create mode 100644 tests/boundary/filesystem/ciRequiredLanes.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseInspection.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseProvider.test.ts create mode 100644 tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts create mode 100644 tests/fixtures/sqlite/database.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 63f16b9780..fc6237dcd1 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -83,6 +83,15 @@ explicit path or endpoint and do not need it. absent metadata does not prove a binary is not Go, and unparsed text records remain explicit. See the [Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md). + +- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect + with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema + first (compact inventory by default), or select an exact ordinary table + and `columns` when rows are needed. Defaults are selected-table schema and + 20 rows; request `schema_scope: "database"` for the complete stored schema. Preserve both database and existing sibling WAL identity; row samples + report truncation and have unspecified order. Use a quiescent snapshot rather + than assuming a live database/WAL pair was copied atomically. See the + [SQLite guide](https://github.com/morluto/rea/blob/main/docs/sqlite-databases.md). - Explicit Linux ELF file for offline layout, symbols, relocations or static mitigation evidence: `inspect_binary_layout`. This target-free operation uses caller-supplied pwntools without opening a disassembler database. Preserve its diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index e2f3230dbc..9dbd2b092a 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -11,6 +11,21 @@ unparsed module-text line. A missing record is an unknown, not proof of a non-Go binary; observed metadata does not authenticate its claims. Function, source and type recovery remain a separate native-analysis question. +## SQLite snapshots + +Use `inspect_sqlite_database` with an explicit local database snapshot for its +schema and optional rows from one exact ordinary table. It requires Node.js +24.x >=24.15 or 26+ with the native `DatabaseSync.limits` API. It also consumes the +existing sibling `-wal`; retain both original digests when citing its result. +Default output is a compact inventory; a selected table expands only its own +schema and samples 20 rows. Select `columns` to avoid irrelevant or large cells, +and request `schema_scope: "database"` only for complete schema details. +Choose `row_limit` when a finite sample is useful and keep truncation and +unspecified row order explicit. Virtual tables, shadow tables and views are +schema facts, not row-read targets. REA inspects private copies and leaves +the source files untouched; obtain a coherent quiescent database/WAL snapshot +before inspection. See [SQLite snapshots](https://github.com/morluto/rea/blob/main/docs/sqlite-databases.md). + ## Recorded Linux crashes Use `inspect_recorded_crash` with an explicit Linux x86-64 ELF core path to diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e882d07265..86f1ba2e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,6 +209,11 @@ jobs: if-no-files-found: error retention-days: 7 + sqlite-runtime: + needs: [changes, build] + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/sqlite-runtime.yml + test: name: Test (coverage + timing) needs: [changes, test-shard] @@ -659,6 +664,7 @@ jobs: static, build, test-shard, + sqlite-runtime, test, conformance-fixtures, windows-native-build, diff --git a/.github/workflows/sqlite-runtime.yml b/.github/workflows/sqlite-runtime.yml new file mode 100644 index 0000000000..6e5bbcd108 --- /dev/null +++ b/.github/workflows/sqlite-runtime.yml @@ -0,0 +1,37 @@ +name: SQLite runtime compatibility + +on: + workflow_call: + +permissions: + contents: read + +env: + HUSKY: "0" + +jobs: + sqlite: + name: SQLite runtime compatibility (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Exercise unavailable-runtime diagnostics, the first supported release, + # and new patches on both supported lines without hand-editing versions. + # The repository-pinned Node 24 version also runs the complete test suite. + node: ["22.x", "24.15.0", "24.x", "26.x"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + check-latest: true + cache: npm + - run: npm ci + - uses: actions/download-artifact@v4 + with: + name: rea-test-runtime + path: . + - name: Verify SQLite discovery, inspection and cleanup + run: npm run test:sqlite:run diff --git a/README.md b/README.md index 83b8f03bc7..a656171284 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Additional tools and host support depend on the target: | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | +| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | | Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) | | Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c906c4176c..07ca2e72df 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,6 +43,7 @@ export default defineConfig({ { text: "Android applications", link: "/android-analysis" }, { text: "Apple applications", link: "/apple-application-analysis" }, { text: "Firmware", link: "/firmware-analysis" }, + { text: "SQLite databases", link: "/sqlite-databases" }, ], }, { diff --git a/docs/cli.md b/docs/cli.md index 3e565c8d79..2d7567d061 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,6 +62,14 @@ unpack .NET single-file hosts or decode IL2CPP metadata. NativeAOT method bodies require native analysis. See the [managed-code guide](managed-code-analysis.md) for supported deployment forms, selection semantics, and coverage limits. +## SQLite snapshots + +For a local database snapshot, use `rea inspect-sqlite-database PATH --json`. +Add `--table NAME --row-limit 20` to read a bounded sample from an ordinary +table. This command does not require a native analysis provider. See +[SQLite database snapshots](sqlite-databases.md) for database/WAL identity, +source-file handling and row-value semantics. + ## Native analysis Inspect embedded Go build metadata without installing Go or configuring a diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md new file mode 100644 index 0000000000..3b8f40b01f --- /dev/null +++ b/docs/sqlite-databases.md @@ -0,0 +1,144 @@ +# SQLite database snapshots + +`inspect_sqlite_database` / `inspect-sqlite-database` inspects one explicitly +selected local SQLite database snapshot without an active native analysis +target. Use it to understand application storage, configuration or retained +database evidence. No separate SQLite installation is required. + +This operation requires Node.js 24.x >=24.15 or 26+ with the native +`DatabaseSync.limits` API. The worker verifies that capability before querying +the copied database. Other REA features retain their published Node.js 22.x, +24.x or 26+ requirements. + +`rea capabilities` reports `inspect_sqlite_database` availability and the +runtime requirement without opening a database. Unsupported runtimes show +`available: false`, `availability_code: "unsupported_version"`, and an upgrade +note while the inventory command still succeeds. + +```sh +rea capabilities --json +rea inspect-sqlite-database ./application.sqlite --json +rea inspect-sqlite-database ./application.sqlite --table settings --column key --column value --row-limit 20 --json +``` + +```json +{ + "name": "inspect_sqlite_database", + "arguments": { + "path": "/artifacts/application.sqlite", + "table": "settings", + "columns": ["key", "value"], + "row_limit": 20 + } +} +``` + +MCP paths must be absolute filesystem paths on the current host. CLI paths +resolve against the operator's working directory. Select a quiescent snapshot: +stop the writer or obtain a consistent database/WAL pair using the application's +backup workflow before inspection. A file copy of a live pair does not establish +an atomic SQLite snapshot, even if no change is detected during inspection. + +## Returned evidence + +Without a table selection, the default `schema_scope` is `inventory`: compact +table metadata and index, view and trigger names, without SQL declarations or +column details. Selecting a table defaults to `selected_table`, which expands +that table and its indexes and triggers. Explicit `schema_scope: "database"` +(or CLI `--schema-scope database`) expands all stored declarations and supported +columns. `schema.completeness` refers to the reported scope; omitted column +details are marked `not_requested`. SQL declarations retain the original text. Virtual and +shadow tables are identified as such; their supported metadata coverage is +reported. If a virtual table cannot be resolved, possible backing tables have +`kind: "unknown"` and an explicit limitation; their rows are refused. Unrelated +ordinary tables remain selectable. Schema inventory does not execute views, +triggers or arbitrary SQL. + +Rows are optional. `table` selects one exact ordinary-table name. `columns` +(CLI repeated `--column`) selects exact visible column names in the requested +order; omission selects all visible columns, including generated columns. +Unselected cells are not evaluated or projected. Column selection requires a +table and at least one name; empty-string identifiers are valid. `row_limit` +requires `table`, defaults to 20 and accepts positive safe integers. Samples +remain subject to the byte and worker resource limits below, regardless of row count. The result reports +the selected limit, returned count and whether more rows were observed. Row +order is unspecified; a truncated sample does not prove anything about omitted +records. View, virtual-table and shadow-table row reads are unsupported. + +SQLite integers are decimal strings so every signed 64-bit value survives JSON +serialization. Real values retain their numbers; `"Infinity"`, `"-Infinity"` +and `"-0"` explicitly preserve infinities and negative zero through JSON. +Text returns the decoded string together with its bytes as hex and the database +encoding, preserving malformed text that the SQLite binding may replace for +display. Blobs are hex, and SQL NULL is a distinct tagged value. + +Database and existing sibling `-wal` files have separate original SHA-256 and +byte counts. The Evidence subject identifies the original database file; +the WAL identity is also necessary to interpret pending committed data. The +SQLite engine version is reported with the observed result. Neither digest is +a claim that the database is healthy, complete or trusted. + +## Source files and failures + +REA copies the selected database and existing sibling WAL to an owned private +temporary directory before SQLite opens them. Source files are not opened by +SQLite, so its locks or shared-memory sidecars cannot modify the original +snapshot. Temporary copies and the owned worker are cleaned up on success, +failure and cancellation. Extension loading and schema trust are disabled. +Empty rollback journals and fully zeroed journal headers left by successful +`TRUNCATE` and `PERSIST` commits are accepted after stable identity checks. +Journal headers are checked both before and after acquisition; unchanged file +metadata alone does not establish that a journal remains inactive. +Other retained rollback journals are refused because recovery is outside this +profile. Journal files are never modified or copied into the worker snapshot. + +A provider admits at most two inspections through acquisition, worker execution +and cleanup. Additional requests receive a typed capacity error before reading +files or creating workers; retry after an active inspection finishes. This +bounds simultaneous input buffering and private snapshot/worker counts. + +The database and WAL together are limited to 256 MiB. SQLite values/records, +SQL statements and the complete worker JSON reply each have a 16 MiB limit. +The owned worker has a 256 MiB JavaScript heap limit and a 30-second inspection +deadline. SQLite native allocations are separate from the JavaScript heap; +overall native heap and total resident memory are not capped. Row limits do not +override these byte and resource limits. Resource-limit failures do not return +an incomplete schema as successful complete evidence. + +MCP also applies its configured response budget to the complete Evidence +envelope. If delivery exceeds that budget, it reports a transport constraint +with guidance for exporting retained Evidence, requesting a matching larger +client/server response budget or using complete CLI JSON output. + +A cancellation received after the worker has completed does not erase the +completed observation. If subsequent private-root cleanup fails, the error +retains the completed Evidence and the resource owner for cleanup retry. MCP +records that Evidence before delivering the error, which carries its retained +evidence ID for later retrieval or export. + +Changed input identities, special files, malformed databases, unsupported +formats and resource failures are reported as errors with the selected path and +meaningful reason. Encrypted databases, database repair, arbitrary SQL, +cross-database queries and live capture are outside this inspection's coverage. + +## Compatibility verification + +Run `npm run test:sqlite` after changing the SQLite adapter or its shared +process, filesystem, CLI or MCP boundaries. It prepares the compiled runtime +and runs SQLite service, snapshot, inspection, cleanup and public CLI/MCP +tests. Directory-based selection includes new SQLite service and boundary +tests automatically. `npm run test:sqlite:run` runs the same cases when the +compiled runtime and generated test artifacts are already current. + +The reusable SQLite CI workflow consumes the shared build artifact and tests +the latest Node 22.x unavailable-runtime diagnostics, the minimum supported +24.15.0 release, and the latest 24.x and 26.x releases. The repository-pinned +runtime also runs the full suite. CI's required gate waits for every selected +SQLite matrix job; a failed, cancelled, skipped or missing selected lane blocks +the gate. Workflow changes themselves select the build and compatibility lane. + +PR checks run against GitHub's merge with the current base branch. A later +change to `main` can introduce failures outside SQLite; inspect the failing +job and recheck the updated merge before treating an earlier green run as +current verification. Compatibility checks detect regressions, but cannot +guarantee that an arbitrary future upstream change remains compatible. diff --git a/package.json b/package.json index faefead3b1..8a58184c8d 100644 --- a/package.json +++ b/package.json @@ -244,6 +244,8 @@ "verify:native-calls": "npm run build:cached && node scripts/verify-native-calls.mjs", "verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial", "test:focused": "node scripts/run-development-tests.mjs focused", + "test:sqlite": "npm run test:prepare && npm run test:sqlite:run", + "test:sqlite:run": "vitest run src/application/sqlite/ tests/boundary/sqlite/ tests/acceptance/sqliteDatabaseCli.test.ts tests/boundary/mcp/serverLifecycleMcp.test.ts", "build:windows-native": "node scripts/build-windows-native.mjs", "verify:windows-native:artifact": "node scripts/verify-windows-native-artifact.mjs", "prepublishOnly": "npm run verify:windows-native:artifact", diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index bea640f55c..8b99efce51 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -24,6 +24,7 @@ const NATIVE_TEST_OWNERS = ["apple", "package"]; const GUARDED_WORKFLOWS = new Set([ ".github/workflows/release.yml", ".github/workflows/real-ghidra-windows.yml", + ".github/workflows/sqlite-runtime.yml", ]); // Sources compiled by build-conformance-fixtures.mjs. The readiness journey // analyzes the built C fixture as well as its own applications. diff --git a/scripts/ci/required.mjs b/scripts/ci/required.mjs index fb5115b8ee..4f65867372 100644 --- a/scripts/ci/required.mjs +++ b/scripts/ci/required.mjs @@ -2,7 +2,7 @@ const jobs = JSON.parse(process.env.CI_JOBS); const outputs = jobs.changes.outputs; const required = ["changes", "static", "test"]; const lanes = { - code: ["build", "test-shard"], + code: ["build", "test-shard", "sqlite-runtime"], runtime: ["windows-native-build", "package-e2e"], package: ["windows-curated"], apple: ["apple-artifacts"], diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 2ecafd9ae9..5cf27d4861 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -29,6 +29,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.evmContracts.EVM_TOOL_CONTRACTS, ...sources.goContracts.GO_TOOL_CONTRACTS, ...sources.wasmContracts.WASM_TOOL_CONTRACTS, + ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ], }, { @@ -207,6 +208,10 @@ export const providerCatalog = (sources) => { identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY, contracts: sources.goContracts.GO_TOOL_CONTRACTS, }, + { + identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY, + contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER, contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 6287f257b7..14c8bf3f89 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -50,6 +50,8 @@ const SOURCE_PATHS = { wasmContracts: "dist/contracts/wasm/wasmToolContracts.js", wasmProvider: "dist/wasm/WabtRelease.js", evmContracts: "dist/contracts/evm/evmToolContracts.js", + sqliteContracts: "dist/contracts/sqlite/sqliteToolContracts.js", + sqliteDomain: "dist/domain/sqlite/sqliteDatabase.js", evmProvider: "dist/evm/EvmoleRelease.js", goContracts: "dist/contracts/go/goToolContracts.js", goProvider: "dist/go/GoBinaryProvider.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index a7a6bbf91b..d4acb4f00c 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PURE_LAYERS = new Set(["domain", "contracts"]); const MIGRATED_PROVIDER_ROOTS = new Set([ + "sqlite", "evm", "go", "wasm", diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ac825bc049..73ffaecf97 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import type { OptionalProviderLoadFailures, OptionalProviderLoadFailure, @@ -341,6 +342,15 @@ const workflowAvailabilityFor = ({ }: AvailabilityContext): Availability | null => { if (name === "inspect_go_binary") return { reason: "available", remediation: null }; + if (name === "inspect_sqlite_database") { + const capability = sqliteDatabaseCapability(process.versions.node); + return capability.available + ? { reason: "available", remediation: null } + : { + reason: "provider_unavailable", + remediation: capability.reason, + }; + } if (name === "trace_web_module_imports") return { reason: "available", diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index c19bd0c167..d67dcc9205 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -4,6 +4,9 @@ import { createServerIdentity } from "../serverIdentity.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; +import { providerCapabilitySet } from "../contracts/toolOutputSchemaPrimitives.js"; +import { providerCapabilitySets } from "./binary/BinarySessionStatus.js"; +import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; /** Read target-free provider status and operation availability. */ export const runSessionStatus = async ( @@ -17,8 +20,15 @@ export const runSessionStatus = async ( if (!config.ok) return { error: projectAnalysisError(config.error) }; const session = await dependencies.createBinarySession(config.value, logger); try { + const status = jsonObjectSchema.parse(session.status()); return { - ...jsonObjectSchema.parse(session.status()), + ...status, + capabilities: [ + ...providerCapabilitySet.array().parse(status.capabilities), + ...providerCapabilitySets([ + sqliteDatabaseCapability(process.versions.node), + ]), + ], server_identity: createServerIdentity({ startedAt: new Date().toISOString(), }), diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 4794bed493..15e0cfd32c 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -198,15 +198,19 @@ type CapabilityAvailabilityCode = Exclude< CapabilityDescriptor["availabilityCode"], undefined >; -type CapabilityProjection = Omit & - ( +// Wire capability projection also covers target-free operations outside binary routing. +type CapabilityProjection = Omit< + CapabilityDescriptor, + "operation" | "available" | "reason" +> & { readonly operation: string } & ( | { readonly available: true; readonly reason: null } | { readonly available: false; readonly reason: string } ); type ProviderCapabilityGroup = ProviderCapabilitySet["groups"][number]; /** Group inline capability facts without merging distinct providers or states. */ -const providerCapabilitySets = ( +/** Project provider descriptors once for binary and target-free status consumers. */ +export const providerCapabilitySets = ( descriptors: readonly CapabilityProjection[], ): ProviderCapabilitySet[] => { const providers = new Map< diff --git a/src/application/sqlite/SqliteDatabaseCapability.test.ts b/src/application/sqlite/SqliteDatabaseCapability.test.ts new file mode 100644 index 0000000000..7ae3a6f94a --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseCapability.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import { expandProviderCapabilities } from "../../contracts/toolOutputSchemaPrimitives.js"; +import { providerCapabilitySets } from "../binary/BinarySessionStatus.js"; +import { sqliteDatabaseCapability } from "./SqliteDatabaseCapability.js"; + +it.each([ + ["22.23.1", false], + ["24.11.0", false], + ["24.14.9", false], + ["24.15.0", true], + ["25.1.0", false], + ["26.0.0", true], + ["24.15.0-rc.1", false], + ["invalid", false], +])( + "reports SQLite inspection availability on Node %s as %s", + (version, available) => { + const [capability] = expandProviderCapabilities( + providerCapabilitySets([sqliteDatabaseCapability(version)]), + ); + expect(capability).toMatchObject({ + operation: "inspect_sqlite_database", + available, + reason: available + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + availability_code: available ? null : "unsupported_version", + limitations: [expect.stringContaining("DatabaseSync.limits")], + }); + }, +); diff --git a/src/application/sqlite/SqliteDatabaseCapability.ts b/src/application/sqlite/SqliteDatabaseCapability.ts new file mode 100644 index 0000000000..f3c0308676 --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseCapability.ts @@ -0,0 +1,33 @@ +import { satisfies } from "semver"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { SQLITE_PROVIDER_IDENTITY } from "../../domain/sqlite/sqliteDatabase.js"; + +const SQLITE_RUNTIME_REQUIREMENT = + "SQLite inspection requires Node.js 24.x >=24.15 or 26+ with the native DatabaseSync.limits API. Other REA features retain their published runtime requirements. The inspection worker verifies the API before querying the snapshot."; + +/** Discover inspection readiness without opening a database or starting a worker. */ +export const sqliteDatabaseCapability = (nodeVersion: string) => { + const contract = toolContract("inspect_sqlite_database"); + const facts = { + operation: contract.name, + provider: SQLITE_PROVIDER_IDENTITY, + effects: { + mutatesArtifact: contract.effects.mutatesTarget, + launchesProcess: contract.effects.launchesProcess, + mayShowUi: contract.effects.changesUiState, + mayAccessNetwork: contract.effects.accessesNetwork, + mayWriteFilesystem: contract.effects.writesFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [SQLITE_RUNTIME_REQUIREMENT], + }; + return satisfies(nodeVersion, "^24.15.0 || >=26.0.0") + ? { ...facts, available: true as const, reason: null } + : { + ...facts, + available: false as const, + reason: SQLITE_RUNTIME_REQUIREMENT, + availabilityCode: "unsupported_version" as const, + }; +}; diff --git a/src/application/sqlite/SqliteDatabasePort.ts b/src/application/sqlite/SqliteDatabasePort.ts new file mode 100644 index 0000000000..ed5322b678 --- /dev/null +++ b/src/application/sqlite/SqliteDatabasePort.ts @@ -0,0 +1,17 @@ +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { InspectSqliteDatabaseInput } from "../../domain/sqlite/sqliteDatabase.js"; +import type { Result } from "../../domain/result.js"; + +/** Offline database inspection boundary shared by CLI and MCP. */ +export interface SqliteDatabasePort { + /** Await and retry provider-owned snapshot cleanup. */ + close?(): Promise; + inspect( + input: InspectSqliteDatabaseInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/sqlite/SqliteDatabaseService.test.ts b/src/application/sqlite/SqliteDatabaseService.test.ts new file mode 100644 index 0000000000..543cd0b027 --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseService.test.ts @@ -0,0 +1,147 @@ +import { expect, it } from "vitest"; +import { SqliteDatabaseService } from "./SqliteDatabaseService.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { err } from "../../domain/result.js"; +import { ok } from "../../domain/result.js"; +import { createAnalysisExecution } from "../AnalysisProvider.js"; + +const fixture = () => ({ + artifact: { path: "/selected.db", sha256: "a".repeat(64), bytes: 4096 }, + wal: null, + engine: { name: "SQLite", version: "3.50.2" }, + database_encoding: "UTF-8", + schema: { + scope: "inventory", + completeness: "complete", + tables: [], + indexes: [], + views: [], + triggers: [], + }, + rows: null, + limitations: [], +}); + +const execution = (value: unknown) => + createAnalysisExecution( + value, + { id: "sqlite", name: "SQLite", version: "3.50.2" }, + { + subject: { path: "/selected.db", sha256: "a".repeat(64), format: "file" }, + locations: [{ kind: "artifact-path", path: "/selected.db" }], + }, + ); + +it.each([ + { path: "relative.db" }, + { path: "/selected.db", approval: true }, + { path: "/selected.db", row_limit: 1 }, + { path: "/selected.db", table: 1 }, + { path: "/selected.db", table: "data", row_limit: 0 }, + { + path: "/selected.db", + table: "data", + row_limit: Number.MAX_SAFE_INTEGER + 1, + }, +])( + "rejects invalid SQLite selection before provider effects: %j", + async (input) => { + const service = new SqliteDatabaseService({ + inspect: () => { + throw new Error("Invalid selection must not read a database"); + }, + }); + expect(await service.inspect(input)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +it("preserves producer failures and rejects pre-cancelled requests before effects", async () => { + const failure = new AnalysisCapabilityUnavailableError( + "rea-sqlite", + "inspect_sqlite_database", + "Selected worker could not start", + ); + const service = new SqliteDatabaseService({ + inspect: () => Promise.resolve(err(failure)), + }); + expect(await service.inspect({ path: "/selected.db" })).toEqual(err(failure)); + const signal = AbortSignal.abort(); + const untouched = new SqliteDatabaseService({ + inspect: () => { + throw new Error("Cancelled selection must not read a database"); + }, + }); + expect( + await untouched.inspect({ path: "/selected.db" }, { signal }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it.each(["path", "digest", "subject", "wal", "table"])( + "rejects producer results with changed caller binding: %s", + async (problem) => { + const value = fixture(); + if (problem === "path") value.artifact.path = "/other.db"; + if (problem === "digest") value.artifact.sha256 = "b".repeat(64); + const observed = execution({ + ...value, + ...(problem === "wal" + ? { wal: { path: "/other.db-wal", sha256: "b".repeat(64), bytes: 32 } } + : {}), + }); + const service = new SqliteDatabaseService({ + inspect: () => + Promise.resolve( + ok({ + ...observed, + ...(problem === "subject" ? { subject: null } : {}), + }), + ), + }); + expect( + await service.inspect({ + path: "/selected.db", + ...(problem === "table" ? { table: "selected" } : {}), + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } }); + }, +); + +it("preserves completed observed evidence when cancellation arrives after provider completion", async () => { + const value = fixture(); + const service = new SqliteDatabaseService({ + inspect: () => Promise.resolve(ok(execution(value))), + }); + expect(await service.inspect({ path: "/selected.db" })).toMatchObject({ + ok: true, + value: { + confidence: "observed", + subject: { + local_path: "/selected.db", + digest: { sha256: "a".repeat(64) }, + }, + normalized_result: value, + }, + }); + const controller = new AbortController(); + const cancelled = new SqliteDatabaseService({ + inspect: () => { + controller.abort(); + return Promise.resolve(ok(execution(value))); + }, + }); + expect( + await cancelled.inspect( + { path: "/selected.db" }, + { signal: controller.signal }, + ), + ).toMatchObject({ + ok: true, + value: { normalized_result: value }, + }); +}); diff --git a/src/application/sqlite/SqliteDatabaseService.ts b/src/application/sqlite/SqliteDatabaseService.ts new file mode 100644 index 0000000000..7f8aaeb73e --- /dev/null +++ b/src/application/sqlite/SqliteDatabaseService.ts @@ -0,0 +1,121 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { SqliteDatabasePort } from "./SqliteDatabasePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { + createEvidence, + type Evidence, + type EvidenceSubjectTarget, +} from "../../domain/evidence.js"; +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, + type SqliteDatabase, + SQLITE_ROW_LIMIT_DEFAULT, + sqliteSchemaScope, + sqliteInspectionParameters, +} from "../../domain/sqlite/sqliteDatabase.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; + +const OPERATION = "inspect_sqlite_database"; + +const matchesSelectedDatabase = ( + report: SqliteDatabase, + input: InspectSqliteDatabaseInput, + subject: EvidenceSubjectTarget, +): boolean => + report.artifact.path === input.path && + subject.path === input.path && + subject.sha256 === report.artifact.sha256 && + subject.format === "file" && + (report.wal === null || report.wal.path === `${input.path}-wal`) && + report.schema.scope === sqliteSchemaScope(input) && + (report.schema.scope !== "selected_table" || + (report.schema.tables.length === 1 && + report.schema.tables[0]?.name === input.table && + report.schema.indexes.every((index) => index.table === input.table) && + report.schema.triggers.every( + (trigger) => trigger.table === input.table, + ) && + report.schema.views.length === 0)) && + (input.table === undefined + ? report.rows === null + : report.rows?.table === input.table && + report.rows.row_limit === (input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT) && + (input.columns === undefined || + (report.rows.columns.length === input.columns.length && + report.rows.columns.every( + (name, index) => name === input.columns?.[index], + )))); + +/** Shared snapshot inspection and caller-selection validation for CLI and MCP. */ +export class SqliteDatabaseService { + constructor(readonly provider: SqliteDatabasePort) {} + + /** Await provider shutdown, retaining failed cleanup owners for retry. */ + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + /** Inspect an explicit database snapshot and preserve its original database/WAL identity. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + const execution = inspected.value; + const report = sqliteDatabaseSchema.safeParse(execution.result); + if ( + !report.success || + execution.subject === null || + !matchesSelectedDatabase(report.data, input.data, execution.subject) + ) + return err( + new AnalysisOutputError( + OPERATION, + "SQLite adapter returned malformed data or changed the selected database identity/table/row limit.", + ), + ); + return ok( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: sqliteInspectionParameters(input.data), + result: jsonValueSchema.parse(report.data), + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + ); + } +} diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts index 560d00c959..e35a20783f 100644 --- a/src/artifacts/readStableArtifact.ts +++ b/src/artifacts/readStableArtifact.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; import { constants, type BigIntStats } from "node:fs"; import { lstat, open, type FileHandle } from "node:fs/promises"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; /** Filesystem boundary for stable reads, including deterministic replacement probes. */ export interface StableArtifactFileSystem { @@ -42,6 +44,7 @@ export const readStableArtifact = async ( maximumBytes: number, signal?: AbortSignal, fileSystem: StableArtifactFileSystem = FILE_SYSTEM, + resourceScope?: ArtifactResourceScope, ): Promise<{ readonly bytes: Buffer; readonly sha256: string }> => { signal?.throwIfAborted(); const before = await fileSystem.lstat(path); @@ -61,6 +64,9 @@ export const readStableArtifact = async ( constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, ), ); + const owner = + resourceScope === undefined ? undefined : new OwnedFileHandle(file); + let failure: unknown; try { const initial = await file.stat({ bigint: true }); if ( @@ -116,7 +122,22 @@ export const readStableArtifact = async ( ); const bytes = Buffer.concat(chunks, count); return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; + } catch (cause: unknown) { + failure = cause; + throw cause; } finally { - await file.close(); + if (owner === undefined || resourceScope === undefined) await file.close(); + else { + const cleanup = await resourceScope.release({ + kind: "file-handle", + resource: path, + handle: owner, + }); + if (cleanup.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + failure ?? cleanup.cause, + ArtifactReaderFailure.cleanupObservation(cleanup.cause, path), + ); + } } }; diff --git a/src/cli.ts b/src/cli.ts index 36c05f6c98..cfb83b71ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; import { registerGoCommands } from "./cli/goCommands.js"; +import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; import { registerFridaCommands } from "./cli/fridaCommands.js"; @@ -95,6 +96,7 @@ export const createCli = ( registerWasmCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerGoCommands(cli, logger); + registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); registerAndroidCommands(cli, logger, environment); diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts new file mode 100644 index 0000000000..49ded4801d --- /dev/null +++ b/src/cli/sqliteCommands.ts @@ -0,0 +1,111 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createSqliteDatabaseService } from "../composition/sqlite.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */ +export const registerSqliteCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const service = createSqliteDatabaseService(environment); + cli.command(CLI_COMMANDS.inspectSqliteDatabase, { + description: + "Inspect SQLite schema and optional ordinary-table rows from a local snapshot", + args: z.object({ + path: z.string().describe("Local SQLite database snapshot"), + }), + options: z.object({ + table: z + .string() + .optional() + .describe("Exact ordinary table name to read"), + column: z + .array(z.string()) + .optional() + .describe( + "Repeat to select exact row columns in the requested order; requires table", + ), + schemaScope: z + .enum(["inventory", "selected_table", "database"]) + .optional() + .describe( + "Compact inventory by default, or selected-table details when table is supplied; database requests full schema", + ), + rowLimit: z + .number() + .int() + .min(1) + .optional() + .describe( + "Positive safe integer row count; default 20; requires table", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, async () => { + const inspection = await service + .inspect( + { + path: resolve(args.path), + ...(options.table === undefined + ? {} + : { table: options.table }), + ...(options.rowLimit === undefined + ? {} + : { row_limit: options.rowLimit }), + ...(options.column === undefined + ? {} + : { columns: options.column }), + ...(options.schemaScope === undefined + ? {} + : { schema_scope: options.schemaScope }), + }, + { signal }, + ) + .then( + (result) => ({ result }), + (cause: unknown) => ({ cause }), + ); + try { + await service.close(); + } catch (cause: unknown) { + const primary = + "cause" in inspection + ? inspection.cause + : inspection.result.ok + ? undefined + : inspection.result.error; + if ( + primary instanceof AnalysisError && + cause instanceof AnalysisError + ) + throw analysisErrorWithCleanupFailure( + primary, + cause, + "inspect_sqlite_database", + ); + if ("cause" in inspection) + throw new AggregateError( + [inspection.cause, cause], + "SQLite inspection and provider cleanup both failed", + { cause: inspection.cause }, + ); + throw cause; + } + if ("cause" in inspection) throw inspection.cause; + return inspection.result.ok + ? inspection.result.value + : projectAnalysisError(inspection.result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 8fa0505f1b..154c49396d 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -1,6 +1,7 @@ /** Canonical names used by every one-shot CLI command registration. */ export const CLI_COMMANDS = Object.freeze({ inspectWasmArtifact: "inspect-wasm-artifact", + inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", inspectWebNetworkCapture: "inspect-web-network-capture", observeWebExecution: "observe-web-execution", diff --git a/src/composition/sqlite.ts b/src/composition/sqlite.ts new file mode 100644 index 0000000000..786684a2a0 --- /dev/null +++ b/src/composition/sqlite.ts @@ -0,0 +1,8 @@ +import { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import { SqliteDatabaseProvider } from "../sqlite/SqliteDatabaseProvider.js"; + +/** Compose local snapshot inspection without opening a native analysis provider. */ +export const createSqliteDatabaseService = ( + environment: Readonly = process.env, +): SqliteDatabaseService => + new SqliteDatabaseService(new SqliteDatabaseProvider(environment)); diff --git a/src/contracts/sqlite/sqliteToolContracts.ts b/src/contracts/sqlite/sqliteToolContracts.ts new file mode 100644 index 0000000000..6ee837c0c5 --- /dev/null +++ b/src/contracts/sqlite/sqliteToolContracts.ts @@ -0,0 +1,42 @@ +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, +} from "../../domain/sqlite/sqliteDatabase.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Snapshot inspection is independent of any active native-analysis target. */ +export const SQLITE_TOOL_CONTRACTS = [ + { + name: "inspect_sqlite_database", + ...toolContractMetadata("inspect_sqlite_database"), + kind: "artifact-provider", + description: + "Inspect an explicit local SQLite database snapshot and its existing sibling WAL without modifying source files or opening a native analysis provider. Requires Node.js 24.x >=24.15 or 26+ with native DatabaseSync.limits support, checked by the worker. Returns observed Evidence with database/WAL SHA-256 and a compact schema inventory. Selecting an exact ordinary table expands only its schema and samples rows (default 20, subject to byte and worker resource limits). Select exact columns in the requested order to avoid unrelated or large values; schema_scope=database explicitly expands the whole schema; returned rows retain 64-bit integers, text bytes and blobs with explicit truncation. No arbitrary SQL, view or virtual-table execution, extension loading, database repair, encryption support or live-snapshot atomicity claim. At most two inspections per provider are admitted; occupied capacity is retryable. An isolated temporary copy and owned worker enforce input, value/record, SQL-statement, JSON-reply, JavaScript-heap and deadline limits; overall native heap is not capped. Supply a quiescent snapshot for coherent database/WAL evidence.", + inputSchema: inspectSqliteDatabaseInputSchema, + outputSchema: evidenceResultOf(sqliteDatabaseSchema), + examples: [ + { + title: "Inspect a compact inventory of a local SQLite snapshot", + input: { path: "/artifacts/application.sqlite" }, + }, + { + title: "Read selected rows from an ordinary table", + input: { + path: "/artifacts/application.sqlite", + table: "settings", + columns: ["key", "value"], + row_limit: 20, + }, + }, + { + title: "Expand the complete stored schema", + input: { + path: "/artifacts/application.sqlite", + schema_scope: "database", + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 8725f30408..216e2b7928 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,6 +1,7 @@ import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; +import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; @@ -37,6 +38,7 @@ export const TOOL_CONTRACTS = [ ...EVM_TOOL_CONTRACTS, ...GO_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, + ...SQLITE_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...APKTOOL_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 151c7d5f49..836d190b19 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -19,6 +19,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { resolve_native_call_targets: "states that it does not execute code", inspect_pe_resources: "states that it does not execute the image", inspect_go_binary: "reads embedded metadata without executing the target", + inspect_sqlite_database: + "uses a trusted inspector worker without executing application code or caller SQL", inspect_binary_layout: "parses the binary offline with operator-configured pwntools", inspect_recorded_crash: "inspects a core file without launching its target", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 11a268c0e9..74b8a96651 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -54,6 +54,11 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { inspect_go_binary: evidence, + inspect_sqlite_database: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), inspect_wasm_artifact: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 8e1138f19f..9e08f29a36 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -21,13 +21,22 @@ export class AnalysisUnsupportedTargetError extends AnalysisError { } /** An analysis or transport resource failure, distinct from malformed input or unsupported coverage. */ +export const ANALYSIS_RESOURCE_KINDS = [ + "memory", + "cpu", + "file-size", + "transport", + "capacity", +] as const; + +/** A measured resource constraint or bounded admission refusal. */ export class AnalysisResourceConstraintError extends AnalysisError { readonly _tag = "AnalysisResourceConstraintError"; readonly remediationAction: string | undefined; constructor( readonly operation: string, - readonly resource: "memory" | "cpu" | "file-size" | "transport", + readonly resource: (typeof ANALYSIS_RESOURCE_KINDS)[number], readonly reason: string, readonly reportedLimits: Readonly> | null, options?: AnalysisErrorOptions & { readonly remediationAction?: string }, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index fd5db4823a..215ed40fc4 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -45,13 +45,15 @@ export const analysisErrorRemediationAction = ( if (error instanceof AnalysisResourceConstraintError) return ( error.remediationAction ?? - (error.resource === "transport" - ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." - : error.resource === "cpu" - ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." - : error.resource === "file-size" - ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." - : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") + (error.resource === "capacity" + ? "Wait for an active analysis to complete, then retry within the reported admission limits." + : error.resource === "transport" + ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." + : error.resource === "cpu" + ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." + : error.resource === "file-size" + ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." + : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") ); if (error instanceof HopperTimeoutError) return error.operation === undefined diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 12597fc945..b166385a08 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -116,6 +116,40 @@ it("distinguishes unsupported target formats from unavailable provider capabilit expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true); }); +it.each([false, true])( + "marks occupied capacity retryable only while cleanup is confirmed: %s", + (cleanupIncomplete) => { + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_sqlite_database", + "capacity", + "Inspection slots are occupied", + { max_concurrent_inspections: 2 }, + cleanupIncomplete + ? { + cleanup: { + reason: "Snapshot removal denied", + resources: ["/private/snapshot"], + }, + } + : undefined, + ), + ); + expect(projected.retryable).toBe(!cleanupIncomplete); + expect(projected.code).toBe( + cleanupIncomplete ? "cleanup_incomplete" : "resource_constraint", + ); + expect(projected.details).toMatchObject({ + resource: "capacity", + reported_limits: { max_concurrent_inspections: 2 }, + }); + expect(projected.remediation.action).toContain("Wait"); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); + }, +); + it.each([ ["memory", null], ["memory", { address_space_bytes: 67108864 }], diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 6c0699eb87..cd0c00987f 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -78,7 +78,11 @@ export const projectAnalysisError = ( code, category: analysisErrorCategory(error), message: analysisErrorUserMessage(error), - retryable: RETRYABLE_CODES.has(code), + retryable: + RETRYABLE_CODES.has(code) || + (code === "resource_constraint" && + error instanceof AnalysisResourceConstraintError && + error.resource === "capacity"), remediation: { action: analysisErrorRemediationAction(error), }, diff --git a/src/domain/sqlite/sqliteDatabase.ts b/src/domain/sqlite/sqliteDatabase.ts new file mode 100644 index 0000000000..60f3c6d3fa --- /dev/null +++ b/src/domain/sqlite/sqliteDatabase.ts @@ -0,0 +1,235 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import type { JsonValue } from "../jsonValue.js"; + +/** Format identity is shared by discovery, catalog, and observed SQLite results. */ +export const SQLITE_PROVIDER_IDENTITY = { + id: "sqlite", + name: "REA SQLite database inspector", + version: null, +} as const; + +/** Default ordinary-table sample size when the caller selects a table. */ +export const SQLITE_ROW_LIMIT_DEFAULT = 20; + +const nonnegative = z.number().int().nonnegative(); +const hex = z.string().regex(/^(?:[0-9a-f]{2})*$/); +const encoding = z.enum(["UTF-8", "UTF-16le", "UTF-16be"]); +const artifactSchema = z.strictObject({ + path: z.string().min(1), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + bytes: nonnegative, +}); + +/** Inspect one frozen database and its sibling WAL, with optional ordinary-table rows. */ +export const inspectSqliteDatabaseInputSchema = z + .strictObject({ + path: localPathStringSchema.describe( + "Absolute path to an offline SQLite database snapshot", + ), + table: z + .string() + .optional() + .describe( + "Exact ordinary table name; views and virtual/shadow tables are not executed", + ), + columns: z + .array(z.string()) + .min(1) + .optional() + .describe( + "Exact row columns in caller-selected order; requires table. Omit to sample all visible columns.", + ), + schema_scope: z + .enum(["inventory", "selected_table", "database"]) + .optional() + .describe( + "Default: compact inventory without a table, or detailed selected-table schema with a table. Database explicitly expands all stored declarations and supported columns.", + ), + row_limit: z + .number() + .int() + .min(1) + .optional() + .describe( + "Selected-table sample size, default 20; subject to byte and worker resource limits", + ), + }) + .superRefine((input, context) => { + if (input.row_limit !== undefined && input.table === undefined) + context.addIssue({ + code: "custom", + path: ["row_limit"], + message: "row_limit requires an explicit table selection", + }); + if (input.columns !== undefined && input.table === undefined) + context.addIssue({ + code: "custom", + path: ["columns"], + message: "columns requires an explicit table selection", + }); + if (input.schema_scope === "selected_table" && input.table === undefined) + context.addIssue({ + code: "custom", + path: ["schema_scope"], + message: "selected_table schema scope requires table", + }); + }) + .meta({ + dependentRequired: { row_limit: ["table"], columns: ["table"] }, + if: { + properties: { schema_scope: { const: "selected_table" } }, + required: ["schema_scope"], + }, + then: { required: ["table"] }, + }); + +/** JSON-safe SQLite cells retain integers, binary content and special REAL values. */ +export const sqliteValueSchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("null") }), + z.strictObject({ + type: z.literal("integer"), + value: z.string().regex(/^-?(?:0|[1-9][0-9]*)$/), + }), + z.strictObject({ + type: z.literal("real"), + value: z.union([z.number(), z.enum(["Infinity", "-Infinity", "-0"])]), + }), + z.strictObject({ + type: z.literal("text"), + value: z.string(), + bytes_hex: hex, + encoding, + }), + z.strictObject({ type: z.literal("blob"), hex }), +]); + +/** Frozen schema facts and explicitly bounded selected records, without arbitrary SQL. */ +export const sqliteDatabaseSchema = z + .strictObject({ + artifact: artifactSchema, + wal: artifactSchema.nullable(), + engine: z.strictObject({ + name: z.literal("SQLite"), + version: z.string().min(1), + }), + database_encoding: encoding, + schema: z.strictObject({ + scope: z.enum(["inventory", "selected_table", "database"]), + completeness: z + .literal("complete") + .describe( + "Complete within the indicated schema scope; inventory omits declarations and column details", + ), + tables: z.array( + z.strictObject({ + name: z.string(), + sql: z.string().nullable().optional(), + root_page: nonnegative, + kind: z + .enum(["table", "virtual", "shadow", "unknown"]) + .describe( + "Unknown means a possible shadow table of an unresolved virtual table; its rows cannot be inspected", + ), + without_rowid: z.boolean(), + strict: z.boolean(), + column_count: nonnegative.nullable(), + columns_completeness: z.enum([ + "complete", + "unsupported", + "not_requested", + ]), + columns: z.array( + z.strictObject({ + cid: nonnegative, + name: z.string(), + declared_type: z.string(), + not_null: z.boolean(), + default_sql: z.string().nullable(), + primary_key_position: nonnegative, + hidden: z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + ]), + }), + ), + }), + ), + indexes: z.array( + z.strictObject({ + name: z.string(), + table: z.string(), + sql: z.string().nullable().optional(), + root_page: nonnegative, + }), + ), + views: z.array( + z.strictObject({ + name: z.string(), + sql: z.string().nullable().optional(), + }), + ), + triggers: z.array( + z.strictObject({ + name: z.string(), + table: z.string(), + sql: z.string().nullable().optional(), + }), + ), + }), + rows: z + .strictObject({ + table: z.string(), + columns: z.array(z.string()), + values: z.array(z.array(sqliteValueSchema)), + row_limit: z.number().int().min(1), + returned_rows: nonnegative, + truncated: z.boolean(), + order: z.literal("unspecified"), + }) + .nullable(), + limitations: z.array(z.string()), + }) + .superRefine((report, context) => { + const rows = report.rows; + if ( + rows !== null && + (rows.returned_rows !== rows.values.length || + rows.returned_rows > rows.row_limit || + rows.values.some((row) => row.length !== rows.columns.length)) + ) + context.addIssue({ + code: "custom", + path: ["rows"], + message: + "Selected row counts and column dimensions must match the retained values", + }); + }); + +export type InspectSqliteDatabaseInput = z.infer< + typeof inspectSqliteDatabaseInputSchema +>; +export type SqliteDatabase = z.infer; +export type SqliteValue = z.infer; + +/** Preserve explicit caller selections while omitting absent optional values from JSON. */ +export const sqliteInspectionParameters = ( + input: InspectSqliteDatabaseInput, +): Readonly> => ({ + path: input.path, + ...(input.table === undefined ? {} : { table: input.table }), + ...(input.columns === undefined ? {} : { columns: input.columns }), + ...(input.schema_scope === undefined + ? {} + : { schema_scope: input.schema_scope }), + ...(input.row_limit === undefined ? {} : { row_limit: input.row_limit }), +}); + +/** One contract owner interprets omitted scope for adapters and selection checks. */ +export const sqliteSchemaScope = ( + input: InspectSqliteDatabaseInput, +): SqliteDatabase["schema"]["scope"] => + input.schema_scope ?? + (input.table === undefined ? "inventory" : "selected_table"); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index 0cf42176e8..5c81c5ce0f 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ANALYSIS_RESOURCE_KINDS } from "../../domain/analysisErrorCore.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { digestSchema } from "../../domain/digests.js"; import { @@ -65,7 +66,7 @@ export const javaScriptWorkerMessageSchema = z.discriminatedUnion("kind", [ kind: z.literal("failure"), ...identity, reason: z.enum(["resource", "output", "io"]), - resource: z.enum(["memory", "cpu", "file-size", "transport"]).nullable(), + resource: z.enum(ANALYSIS_RESOURCE_KINDS).nullable(), limits: z.record(z.string(), jsonValueSchema).nullable(), message: z.string(), }), diff --git a/src/main/transport.ts b/src/main/transport.ts index f9a5cd0ea8..eb7b157d75 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -56,6 +56,17 @@ export const startMcpTransport = async ( ); } const androidProviders: AndroidAnalysisPort[] = []; + const servers = new Set>(); + const closeServers = async (): Promise => { + const results = await Promise.allSettled( + [...servers].map((server) => server.close()), + ); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) + throw new AggregateError(failures, "REA server cleanup failed"); + }; const closeAndroid = async (): Promise => { const results = await Promise.allSettled( androidProviders.map((provider) => provider.close()), @@ -77,7 +88,7 @@ export const startMcpTransport = async ( serverContext.environment, ); androidProviders.push(android); - return (dependencies.createServer ?? createServer)( + const server = (dependencies.createServer ?? createServer)( { kind: "session", session }, { logger: serverContext.logger, @@ -87,6 +98,14 @@ export const startMcpTransport = async ( androidAnalysis: android, }, ); + // The SDK can discard a server even when its provider cleanup failed. + servers.add(server); + const closeServer = server.close.bind(server); + server.close = async () => { + await closeServer(); + servers.delete(server); + }; + return server; }, { onerror: () => { @@ -96,10 +115,12 @@ export const startMcpTransport = async ( }, ); } catch (cause: unknown) { - const [binaryCleanup, androidCleanup] = await Promise.allSettled([ - session.close(), - closeAndroid(), - ]); + const [binaryCleanup, androidCleanup, serverCleanup] = + await Promise.allSettled([ + session.close(), + closeAndroid(), + closeServers(), + ]); serverLogger.error( { error: cause instanceof Error ? cause.message : String(cause), @@ -115,7 +136,7 @@ export const startMcpTransport = async ( ); dependencies.writeStderr(`${cleanupError.message}\n`); } - for (const cleanup of [binaryCleanup, androidCleanup]) { + for (const cleanup of [binaryCleanup, androidCleanup, serverCleanup]) { if (cleanup.status === "rejected") { const reason = cleanup.reason instanceof Error @@ -130,5 +151,23 @@ export const startMcpTransport = async ( } return { ok: false }; } - return { ok: true, handle, closeAndroid, closeOptionalProviders }; + return { + ok: true, + handle: { + close: async () => { + const results = await Promise.allSettled([ + handle.close(), + closeServers(), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "REA transport cleanup failed"); + }, + }, + closeAndroid, + closeOptionalProviders, + }; }; diff --git a/src/server/contractToolHandler.ts b/src/server/contractToolHandler.ts index ea3e8935b3..0ad6fcd160 100644 --- a/src/server/contractToolHandler.ts +++ b/src/server/contractToolHandler.ts @@ -61,8 +61,19 @@ export const createEvidenceToolHandler = const result = await logToolExecution(logger, name, () => operation(input, { signal: context.mcpReq.signal }), ); - if (!result.ok) + if (!result.ok) { + const partial = result.error.partialObservation; + if (partial !== undefined && "evidence_id" in partial) { + const recorded = recordEvidence?.(partial); + if (recorded !== undefined) + return server.delivery.toRecordedPartialErrorToolResult( + result.error, + partial.evidence_id, + recorded, + ); + } return server.delivery.toCallToolResult(result, contract); + } const recorded = recordEvidence?.(result.value); return server.delivery.toEvidenceToolResult( result.value, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 844cc5d72f..19d169bd10 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -7,6 +7,9 @@ import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { evmInterfaceModule } from "../composition/evm.js"; import { registerEvmTools } from "./registerEvmTools.js"; +import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import { createSqliteDatabaseService } from "../composition/sqlite.js"; +import { registerSqliteTools } from "./registerSqliteTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; import { createGoBinaryService } from "../composition/go.js"; @@ -118,6 +121,7 @@ export interface CreateServerOptions { readonly delivery?: ToolResultDelivery; readonly wasmArtifact?: WasmArtifactService; readonly evmInterface?: EvmInterfaceService; + readonly sqliteDatabase?: SqliteDatabaseService; readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; readonly goBinary?: GoBinaryService; @@ -243,6 +247,8 @@ export const createServer = ( const delivery = selectToolResultDelivery(environment, options.delivery); const evmInterface = options.evmInterface ?? evmInterfaceModule.create(environment); + const sqliteDatabase = + options.sqliteDatabase ?? createSqliteDatabaseService(environment); const binaryLayout = options.binaryLayout ?? createBinaryLayoutService(environment); const recordedCrash = @@ -262,6 +268,7 @@ export const createServer = ( environment, delivery, evmInterface, + sqliteDatabase, binaryLayout, recordedCrash, webSourceLocation, @@ -302,6 +309,7 @@ export const createServer = ( adbDevice.close(), flutter.close(), evmInterface.close(), + sqliteDatabase.close(), binaryLayout.close(), recordedCrash.close(), webSourceLocation.close(), @@ -520,6 +528,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerSqliteTools( + server, + options.sqliteDatabase ?? createSqliteDatabaseService(environment), + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerRecordedCrashTools( server, options.recordedCrash ?? createRecordedCrashService(environment), diff --git a/src/server/registerSqliteTools.ts b/src/server/registerSqliteTools.ts new file mode 100644 index 0000000000..8d2e07d320 --- /dev/null +++ b/src/server/registerSqliteTools.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabaseService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind snapshot inspection to its named contract and additive session Evidence. */ +export const registerSqliteTools = ( + server: EvidenceMcpServer, + service: SqliteDatabaseService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_sqlite_database"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts new file mode 100644 index 0000000000..06bf4e71ed --- /dev/null +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -0,0 +1,165 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/sqliteDatabase.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +/** Preserve diagnostics independently of worker exit and private-root cleanup. */ +export const capturedSqliteOutput = ( + snapshot: ProviderProcessSnapshot, +): AnalysisCapturedOutput => ({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + truncated: snapshot.diagnosticTruncated === true, +}); + +/** Preserve artifact, caller, process, resource and output failure meanings. */ +export const sqliteDatabaseFailure = ( + cause: unknown, + path: string, + phase: string, + signal?: AbortSignal, +): AnalysisError => { + const operation = "inspect_sqlite_database"; + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure) { + if (cause.cleanup !== undefined) + return new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { + reason: cause.cleanup.reason, + previous_error: + cause.cause === undefined + ? null + : projectAnalysisError( + signal?.aborted + ? new AnalysisCancelledError(operation, { + cause: cause.cause, + }) + : sqliteDatabaseFailure(cause.cause, path, phase), + ), + }, + { operation, cause }, + ); + if (cause.reason === "io" && cause.cause !== undefined) + return sqliteDatabaseFailure(cause.cause, path, phase, signal); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(operation, path, cause.message, { + cause, + }); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { cause }); + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: cause.message, + }, + ]); + } + if (cause instanceof Error && cause.name === "AbortError") + return new AnalysisCancelledError(operation, { cause }); + if (cause instanceof OwnedCommandFailure) { + const projected = ownedCommandFailure(cause); + if (projected !== undefined) return projected; + } + if (phase === "artifact-read" && cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected database file set could not be read (${String(cause.code)}): ${path}`, + }, + ]); + } + return new ProviderAdapterError(SQLITE_PROVIDER_IDENTITY.id, operation, { + cause, + diagnostics: { + phase, + path, + reason: cause instanceof Error ? cause.message : String(cause), + ...(cause instanceof OwnedCommandFailure && cause.snapshot !== null + ? { + failure_kind: cause.reason, + exit_code: cause.snapshot.exitCode ?? null, + signal: cause.snapshot.signal ?? null, + captured_output: { ...capturedSqliteOutput(cause.snapshot) }, + } + : {}), + }, + }); +}; + +const ownedCommandFailure = ( + cause: OwnedCommandFailure, +): AnalysisError | undefined => { + const operation = "inspect_sqlite_database"; + const capturedOutput = + cause.snapshot === null ? undefined : capturedSqliteOutput(cause.snapshot); + const options = { + cause, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }; + if (cause.cleanupFailure !== null) + return new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + cause.resources, + { + reason: cause.cleanupFailure, + previous_error: { + failure_kind: cause.reason, + message: cause.message, + }, + ...(capturedOutput === undefined + ? {} + : { captured_output: { ...capturedOutput } }), + }, + { operation, cause }, + ); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, options); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + operation, + SQLITE_DATABASE_LIMITS.timeoutMs, + options, + ); + if (cause.reason === "output-limit") + return new AnalysisOutputError(operation, cause.message, options); + if ( + cause.snapshot?.stderr.text.includes("JavaScript heap out of memory") === + true + ) + return new AnalysisResourceConstraintError( + operation, + "memory", + "Owned SQLite worker reported JavaScript heap exhaustion.", + { + javascript_heap_bytes: 256 * 1024 * 1024, + }, + options, + ); + return undefined; +}; diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts new file mode 100644 index 0000000000..f5f92bdf49 --- /dev/null +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -0,0 +1,514 @@ +import { DatabaseSync } from "node:sqlite"; +import { z } from "zod"; +import { jsonParts } from "../domain/jsonSerialization.js"; +import { + SQLITE_ROW_LIMIT_DEFAULT, + sqliteSchemaScope, + type InspectSqliteDatabaseInput, + type SqliteDatabase, + type SqliteValue, +} from "../domain/sqlite/sqliteDatabase.js"; +import { + SQLITE_DATABASE_LIMITS, + SqliteInspectionFailure, +} from "./SqliteDatabaseLimits.js"; + +const natural = z + .bigint() + .nonnegative() + .max(BigInt(Number.MAX_SAFE_INTEGER)) + .transform(Number); +const schemaRow = z.object({ + type: z.enum(["table", "index", "view", "trigger"]), + name: z.instanceof(Uint8Array), + tbl_name: z.instanceof(Uint8Array), + rootpage: natural, + sql: z.instanceof(Uint8Array).nullable().optional(), +}); +const tableRow = z.object({ + schema: z.string(), + name: z.string(), + type: z.enum(["table", "view", "virtual", "shadow"]), + ncol: natural, + wr: z.bigint(), + strict: z.bigint(), +}); +const columnRow = z.object({ + cid: natural, + name: z.string(), + type: z.string(), + notnull: z.bigint(), + dflt_value: z.string().nullable(), + pk: natural, + hidden: z + .bigint() + .transform(Number) + .pipe(z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])), +}); +const arrayRow = z.array( + z.union([ + z.null(), + z.bigint(), + z.custom( + (value) => typeof value === "number" && !Number.isNaN(value), + ), + z.string(), + ]), +); +const encodingSchema = z.enum(["UTF-8", "UTF-16le", "UTF-16be"]); +const cellByteLengths = z.array(z.bigint().nonnegative()); +type DatabaseEncoding = z.output; +type Inspection = Pick< + SqliteDatabase, + "engine" | "database_encoding" | "schema" | "rows" | "limitations" +>; +type SelectedTableFacts = Pick< + SqliteDatabase["schema"]["tables"][number], + "name" | "kind" | "columns" +>; + +class OutputBudget { + #bytes = 1024; + retain(value: unknown): void { + // Count bounded encoded parts, including escapes, without allocating a + // document-sized string just to decide whether it fits the reply. + this.#bytes += 1; + for (const part of jsonParts(value)) { + this.#bytes += Buffer.byteLength(part); + if (this.#bytes > SQLITE_DATABASE_LIMITS.outputBytes) + throw new SqliteInspectionFailure( + "output-limit", + "Complete SQLite evidence exceeds the 16 MiB JSON reply budget; no partial success was returned.", + ); + } + } + beforeDecode(row: z.output): void { + let minimumBytes = this.#bytes; + for (const value of row) + if (typeof value === "string") minimumBytes += Buffer.byteLength(value); + if (minimumBytes > SQLITE_DATABASE_LIMITS.outputBytes) + throw new SqliteInspectionFailure( + "output-limit", + "Selected cells exceed the 16 MiB reply budget before text/hex expansion.", + ); + } + beforeProjection(lengths: readonly bigint[]): void { + const encodedBytes = lengths.reduce( + (total, length) => total + length * 2n, + BigInt(lengths.length * 64), + ); + if ( + encodedBytes + BigInt(this.#bytes) > + BigInt(SQLITE_DATABASE_LIMITS.outputBytes) + ) + throw new SqliteInspectionFailure( + "output-limit", + "Selected row byte lengths exceed the 16 MiB reply budget before hex projection.", + ); + } +} +const quoteIdentifier = (name: string): string => + `"${name.replaceAll('"', '""')}"`; +// SQLite folds only ASCII letters when matching virtual/shadow table names. +const sqliteIdentifierKey = (name: string): string => + name.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +const textEncoding = (encoding: DatabaseEncoding): string => + encoding === "UTF-8" + ? "utf-8" + : encoding === "UTF-16le" + ? "utf-16le" + : "utf-16be"; + +const decodeSchemaText = ( + value: Uint8Array, + encoding: DatabaseEncoding, + label: string, +): string => { + try { + return new TextDecoder(textEncoding(encoding), { + fatal: true, + ignoreBOM: true, + }).decode(value); + } catch (cause: unknown) { + throw new SqliteInspectionFailure( + "format", + `SQLite schema ${label} contains malformed ${encoding} text: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } +}; + +const cell = ( + value: z.output[number], + encoding: DatabaseEncoding, +): SqliteValue => { + if (value === null) return { type: "null" }; + if (typeof value === "bigint") + return { type: "integer", value: String(value) }; + if (typeof value === "number") + return { + type: "real", + value: + value === Infinity + ? "Infinity" + : value === -Infinity + ? "-Infinity" + : Object.is(value, -0) + ? "-0" + : value, + }; + const hex = value.slice(1).toLowerCase(); + if (!/^(?:[0-9a-f]{2})*$/.test(hex)) + throw new SqliteInspectionFailure( + "format", + "SQLite returned an invalid encoded cell.", + ); + if (value[0] === "b") return { type: "blob", hex }; + if (value[0] !== "t") + throw new SqliteInspectionFailure( + "format", + "SQLite returned an unknown encoded cell type.", + ); + return { + type: "text", + value: new TextDecoder(textEncoding(encoding), { ignoreBOM: true }).decode( + Buffer.from(hex, "hex"), + ), + bytes_hex: hex, + encoding, + }; +}; + +const readColumns = ( + db: DatabaseSync, + name: string, + budget: OutputBudget, +): SqliteDatabase["schema"]["tables"][number]["columns"] => { + const columns: SqliteDatabase["schema"]["tables"][number]["columns"] = []; + // Direct PRAGMA syntax cannot be shadowed by a stored table or view. + for (const raw of db + .prepare(`PRAGMA main.table_xinfo(${quoteIdentifier(name)})`) + .iterate()) { + const value = columnRow.parse(raw); + const column = { + cid: value.cid, + name: value.name, + declared_type: value.type, + not_null: value.notnull !== 0n, + default_sql: value.dflt_value, + primary_key_position: value.pk, + hidden: value.hidden, + }; + budget.retain(column); + columns.push(column); + } + return columns; +}; + +const readSchema = ( + db: DatabaseSync, + input: InspectSqliteDatabaseInput, + context: { + readonly budget: OutputBudget; + readonly limitations: string[]; + readonly encoding: DatabaseEncoding; + }, +): { + schema: SqliteDatabase["schema"]; + selectedTable: SelectedTableFacts | undefined; +} => { + const { budget, limitations, encoding } = context; + const scope = sqliteSchemaScope(input); + const selectedName = scope === "selected_table" ? input.table : undefined; + if (scope === "selected_table" && selectedName === undefined) + throw new SqliteInspectionFailure( + "selection", + "Selected-table schema scope requires an exact table name.", + ); + let selectedTable: SelectedTableFacts | undefined; + const metadata = new Map>(); + // Names bound the minimum eventual schema output while metadata is collected. + // The full schema objects below account for them in the actual reply budget. + const metadataBudget = new OutputBudget(); + for (const raw of db.prepare("PRAGMA main.table_list").iterate()) { + const value = tableRow.parse(raw); + if (value.schema === "main") { + metadataBudget.retain(value.name); + metadata.set(value.name, value); + } + } + const unresolvedVirtualTables = [...metadata.values()] + .filter((value) => value.type === "virtual" && value.ncol === 0) + .map((value) => ({ + name: value.name, + prefix: sqliteIdentifierKey(`${value.name}_`), + })); + const schema: SqliteDatabase["schema"] = { + scope, + completeness: "complete", + tables: [], + indexes: [], + views: [], + triggers: [], + }; + // Raw bytes preserve text without hex expansion; avoid sorter records that + // duplicate large names or definitions beyond SQLite's native record limit. + const declaration = scope === "inventory" ? "" : ", CAST(sql AS BLOB) AS sql"; + const selection = + scope === "selected_table" ? " WHERE tbl_name = ? COLLATE BINARY" : ""; + const statement = db.prepare( + `SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage${declaration} FROM main.sqlite_schema${selection}`, + ); + for (const raw of statement.iterate( + ...(selectedName === undefined ? [] : [selectedName]), + )) { + const parsed = schemaRow.parse(raw); + const value = { + ...parsed, + name: decodeSchemaText(parsed.name, encoding, "name"), + tbl_name: decodeSchemaText(parsed.tbl_name, encoding, "table"), + sql: + parsed.sql === undefined + ? undefined + : parsed.sql === null + ? null + : decodeSchemaText(parsed.sql, encoding, "sql"), + }; + if (value.type === "table") { + const listed = metadata.get(value.name); + if (listed === undefined || listed.type === "view") + throw new SqliteInspectionFailure( + "format", + `SQLite schema/table inventory mismatch: ${value.name}`, + ); + const identifier = sqliteIdentifierKey(value.name); + const possibleShadowParent = + listed.type === "table" + ? unresolvedVirtualTables.find((parent) => + identifier.startsWith(parent.prefix), + ) + : undefined; + const table: SqliteDatabase["schema"]["tables"][number] = { + name: value.name, + ...(scope === "inventory" ? {} : { sql: value.sql }), + root_page: value.rootpage, + kind: possibleShadowParent === undefined ? listed.type : "unknown", + without_rowid: listed.wr !== 0n, + strict: listed.strict !== 0n, + column_count: + listed.type === "virtual" && listed.ncol === 0 ? null : listed.ncol, + columns_completeness: "complete", + columns: [], + }; + if (possibleShadowParent !== undefined) + limitations.push( + `SQLite could not resolve virtual table ${possibleShadowParent.name}. Table ${value.name} may be its shadow table; its kind is unknown and row inspection is unavailable.`, + ); + budget.retain(table); + if (scope === "inventory") table.columns_completeness = "not_requested"; + if (listed.type === "virtual" && scope !== "inventory") { + table.columns_completeness = "unsupported"; + limitations.push( + `Virtual table column expansion is not performed: ${value.name}. Its complete stored CREATE statement is retained.`, + ); + } else if ( + listed.type !== "virtual" && + (scope !== "inventory" || input.table === value.name) + ) { + const columns = readColumns( + db, + value.name, + scope === "inventory" ? new OutputBudget() : budget, + ); + if (input.table === value.name) + selectedTable = { name: table.name, kind: table.kind, columns }; + if (scope !== "inventory") table.columns = columns; + } + if (input.table === value.name && selectedTable === undefined) + selectedTable = { + name: table.name, + kind: table.kind, + columns: table.columns, + }; + schema.tables.push(table); + } else if (value.type === "index") { + const index = { + name: value.name, + table: value.tbl_name, + ...(scope === "inventory" ? {} : { sql: value.sql }), + root_page: value.rootpage, + }; + budget.retain(index); + schema.indexes.push(index); + } else if (value.type === "view") { + const view = { + name: value.name, + ...(scope === "inventory" ? {} : { sql: value.sql }), + }; + budget.retain(view); + schema.views.push(view); + } else { + const trigger = { + name: value.name, + table: value.tbl_name, + ...(scope === "inventory" ? {} : { sql: value.sql }), + }; + budget.retain(trigger); + schema.triggers.push(trigger); + } + } + return { schema, selectedTable }; +}; + +const readRows = ( + db: DatabaseSync, + input: InspectSqliteDatabaseInput, + table: SelectedTableFacts | undefined, + context: { + readonly encoding: DatabaseEncoding; + readonly budget: OutputBudget; + }, +): SqliteDatabase["rows"] => { + const { encoding, budget } = context; + if (input.table === undefined) return null; + if (table === undefined) + throw new SqliteInspectionFailure( + "selection", + `Selected ordinary table does not exist with that exact name: ${input.table}`, + ); + if (table.kind === "unknown") + throw new SqliteInspectionFailure( + "selection", + `SQLite cannot determine whether selected table ${input.table} is a shadow table of an unresolved virtual table. Row inspection requires a confirmed ordinary table.`, + ); + if (table.kind !== "table") + throw new SqliteInspectionFailure( + "selection", + `Rows from ${table.kind} table ${input.table} are outside the ordinary-table inspection profile.`, + ); + const limit = input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT; + const visibleColumns = table.columns + .filter((column) => column.hidden !== 1) + .map((column) => column.name); + const columns = input.columns ?? visibleColumns; + const available = new Set(visibleColumns); + for (const name of columns) + if (!available.has(name)) + throw new SqliteInspectionFailure( + "selection", + `Selected column does not exist with that exact name in table ${input.table}: ${name}`, + ); + const projection = columns + .map((name) => { + const column = quoteIdentifier(name); + return `CASE typeof(${column}) WHEN 'text' THEN 't' || hex(CAST(${column} AS BLOB)) WHEN 'blob' THEN 'b' || hex(${column}) ELSE ${column} END`; + }) + .join(", "); + const source = `main.${quoteIdentifier(input.table)} NOT INDEXED`; + const lengths = db.prepare( + `SELECT ${columns + .map((name) => { + const column = quoteIdentifier(name); + return `CASE typeof(${column}) WHEN 'text' THEN length(CAST(${column} AS BLOB)) WHEN 'blob' THEN length(${column}) ELSE 0 END`; + }) + .join(", ")} FROM ${source} LIMIT ?`, + ); + lengths.setReturnArrays(true); + for (const raw of lengths.iterate(limit)) + budget.beforeProjection(cellByteLengths.parse(raw)); + const statement = db.prepare(`SELECT ${projection} FROM ${source} LIMIT ?`); + statement.setReturnArrays(true); + const rows: NonNullable = { + table: input.table, + columns, + values: [], + row_limit: limit, + returned_rows: 0, + truncated: false, + order: "unspecified", + }; + for (const raw of statement.iterate(limit)) { + const rawRow = arrayRow.parse(raw); + // Hex expansion already belongs to the retained-output budget, before decoding/copying cells. + budget.beforeDecode(rawRow); + const values = rawRow.map((value) => cell(value, encoding)); + budget.retain(values); + rows.values.push(values); + } + rows.returned_rows = rows.values.length; + rows.truncated = + db.prepare(`SELECT 1 FROM ${source} LIMIT 1 OFFSET ?`).get(limit) !== + undefined; + return rows; +}; + +/** Query only a provider-owned snapshot; no caller SQL, extensions, views or virtual rows. */ +export const inspectSqliteDatabaseSnapshot = ( + snapshotPath: string, + input: InspectSqliteDatabaseInput, +): Inspection => { + const db = new DatabaseSync(snapshotPath, { + readOnly: true, + allowExtension: false, + readBigInts: true, + enableDoubleQuotedStringLiterals: false, + timeout: 0, + limits: { + length: SQLITE_DATABASE_LIMITS.outputBytes, + sqlLength: SQLITE_DATABASE_LIMITS.outputBytes, + }, + }); + try { + const limits: unknown = db.limits; + const supported = z + .object({ + length: z + .number() + .int() + .positive() + .max(SQLITE_DATABASE_LIMITS.outputBytes), + sqlLength: z + .number() + .int() + .positive() + .max(SQLITE_DATABASE_LIMITS.outputBytes), + }) + .safeParse(limits); + if (!supported.success) + throw new SqliteInspectionFailure( + "unavailable", + "SQLite inspection requires the native DatabaseSync.limits API to bound untrusted values and SQL. Use Node.js 24.15 or newer in the supported 24.x line, or a compatible Node.js 26+ runtime.", + ); + db.exec( + "PRAGMA trusted_schema=OFF; PRAGMA query_only=ON; PRAGMA mmap_size=0; PRAGMA cell_size_check=ON; BEGIN;", + ); + const version = z + .object({ version: z.string() }) + .parse(db.prepare("SELECT sqlite_version() AS version").get()).version; + const encoding = z + .object({ encoding: encodingSchema }) + .parse(db.prepare("PRAGMA encoding").get()).encoding; + const limitations = [ + "Inspects an offline stable file set. Filesystem identity checks detect observed acquisition changes; they do not establish an atomic snapshot of an actively changing database. Supply an application-created backup or quiescent database/WAL pair.", + "The source database and WAL are never opened by SQLite or modified. A private copy may acquire its own WAL-index sidecar and is removed after inspection.", + "SQLite reports current schema and selected committed rows from its valid database/WAL state. Deleted records, uncommitted or invalid WAL tails, encrypted databases, forensic recovery, and full integrity verification are outside this profile.", + "Stored CREATE statements preserve index/view/trigger definitions. Views, triggers, and virtual/shadow table rows are not executed; virtual-table column expansion is unsupported.", + "Selected rows have unspecified order and are a bounded sample. Text display may replace invalid sequences; bytes_hex and encoding preserve SQLite's text byte representation, not original on-disk page offsets.", + "Resource boundaries: at most two concurrent inspections per provider; combined input 256 MiB per inspection, complete worker JSON 16 MiB, native SQLite value/record and SQL statement lengths 16 MiB, owned-worker JavaScript heap 256 MiB and supervised deadline 30 seconds. These are distinct from total resident memory; an overall native SQLite heap cap is not claimed. Failures return no partial success.", + ]; + const budget = new OutputBudget(); + const { schema, selectedTable } = readSchema(db, input, { + budget, + limitations, + encoding, + }); + return { + engine: { name: "SQLite", version }, + database_encoding: encoding, + schema, + rows: readRows(db, input, selectedTable, { encoding, budget }), + limitations, + }; + } finally { + db.close(); + } +}; diff --git a/src/sqlite/SqliteDatabaseLimits.ts b/src/sqlite/SqliteDatabaseLimits.ts new file mode 100644 index 0000000000..99df7ed9d9 --- /dev/null +++ b/src/sqlite/SqliteDatabaseLimits.ts @@ -0,0 +1,23 @@ +/** Bound frozen input, retained JSON and worker allocation before parsing untrusted files. */ +export const SQLITE_DATABASE_LIMITS = { + concurrentInspections: 2, + inputBytes: 256 * 1024 * 1024, + outputBytes: 16 * 1024 * 1024, + timeoutMs: 30_000, + diagnosticBytes: 1024 * 1024, +} as const; +/** Internal rejection retains the failed boundary without returning partial success. */ +export class SqliteInspectionFailure extends Error { + constructor( + readonly reason: + | "format" + | "selection" + | "unsupported" + | "unavailable" + | "output-limit" + | "memory", + message: string, + ) { + super(message); + } +} diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts new file mode 100644 index 0000000000..c44185616f --- /dev/null +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -0,0 +1,410 @@ +import { + createAnalysisExecution, + type AnalysisExecution, + type ExecutionOptions, +} from "../application/AnalysisProvider.js"; +import type { SqliteDatabasePort } from "../application/sqlite/SqliteDatabasePort.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { + AnalysisCapturedOutput, + AnalysisError, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { createEvidence } from "../domain/evidence.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + inspectSqliteDatabaseInputSchema, + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, + SQLITE_PROVIDER_IDENTITY, + sqliteInspectionParameters, +} from "../domain/sqlite/sqliteDatabase.js"; +import { isAbsolute } from "node:path"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; +import { sqliteDatabaseFailure } from "./SqliteDatabaseFailures.js"; +import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +import { + executeSqliteDatabaseWorker, + type SqliteWorkerLauncher, +} from "./SqliteDatabaseWorkerExecution.js"; + +const operation = "inspect_sqlite_database"; +type SqliteOutcome = Result; +type SqliteRuntimeRoot = Pick; +interface PendingSnapshotCleanup { + readonly input: InspectSqliteDatabaseInput; + readonly result: SqliteOutcome; + readonly output: AnalysisCapturedOutput | undefined; + process: ProviderProcessSupervisor | undefined; +} +/** Inspect a provider-owned copy through a cancellable SQLite child, without source writes. */ +export class SqliteDatabaseProvider implements SqliteDatabasePort { + readonly #active = new Set>(); + readonly #artifactResources = new ArtifactResourceScope(); + readonly #pendingCleanup = new Map< + SqliteRuntimeRoot, + PendingSnapshotCleanup + >(); + #closed = false; + #closePromise: Promise | undefined; + #retryPromise: Promise> | undefined; + constructor( + readonly environment: Readonly = process.env, + readonly launcher?: SqliteWorkerLauncher, + readonly createRuntime: () => Promise< + Pick + > = () => PrivateRuntimeRoot.create({ prefix: "rea-sqlite-database-" }), + ) {} + + /** Return exact original DB/WAL identities and current schema/selected committed rows. */ + async inspect( + input: InspectSqliteDatabaseInput, + options?: ExecutionOptions, + ): Promise { + if (this.#closed || options?.signal?.aborted) + return err(new AnalysisCancelledError(operation)); + if (this.#active.size >= SQLITE_DATABASE_LIMITS.concurrentInspections) + return err( + new AnalysisResourceConstraintError( + operation, + "capacity", + "SQLite inspection capacity is occupied. Retry after an active inspection completes.", + { + active_inspections: this.#active.size, + max_concurrent_inspections: + SQLITE_DATABASE_LIMITS.concurrentInspections, + input_bytes_per_inspection: SQLITE_DATABASE_LIMITS.inputBytes, + }, + { + remediationAction: + "Retry after an active SQLite inspection completes.", + }, + ), + ); + const inspection = this.#artifactResources + .run(() => this.#inspect(input, options)) + .catch((cause: unknown) => + err( + selectedFailure( + sqliteDatabaseFailure( + cause, + input.path, + "artifact-read", + options?.signal, + ), + options?.signal, + ), + ), + ); + this.#active.add(inspection); + try { + return await inspection; + } finally { + this.#active.delete(inspection); + } + } + + /** Await inspections and retry retained descriptors, workers, and snapshot roots. */ + close(): Promise { + this.#closed = true; + this.#closePromise ??= Promise.allSettled(this.#active) + .then(async () => { + let descriptorFailure: AnalysisError | undefined; + try { + await this.#artifactResources.close(); + } catch (cause: unknown) { + const cleanup = ArtifactReaderFailure.cleanupObservation( + cause, + "SQLite artifact descriptors", + ); + descriptorFailure = new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + cleanup.resources, + { reason: cleanup.reason }, + { operation, cause }, + ); + } + const cleaned = await this.#retryCleanup(); + if (descriptorFailure !== undefined && !cleaned.ok) + throw new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + [ + ...descriptorFailure.cleanupResources, + ...cleaned.error.cleanupResources, + ], + { + failures: [ + projectAnalysisError(descriptorFailure), + projectAnalysisError(cleaned.error), + ], + }, + { operation }, + ); + if (descriptorFailure !== undefined) throw descriptorFailure; + if (!cleaned.ok) throw cleaned.error; + }) + .catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + #retryCleanup(): Promise> { + this.#retryPromise ??= this.#cleanupPending().finally(() => { + this.#retryPromise = undefined; + }); + return this.#retryPromise; + } + + async #cleanupPending(): Promise> { + const failures: AnalysisError[] = []; + for (const [root, pending] of [...this.#pendingCleanup]) { + if (pending.process !== undefined) { + const stopped = await pending.process.stop(); + if (stopped.status === "incomplete") { + failures.push( + new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + [ + pending.process.launch.ownership?.runId ?? + "owned-sqlite-worker", + root.path, + ], + { + reason: stopped.reason, + previous_error: pending.result.ok + ? null + : projectAnalysisError(pending.result.error), + ...(pending.output === undefined + ? {} + : { captured_output: { ...pending.output } }), + }, + { operation }, + ), + ); + continue; + } + pending.process = undefined; + } + const cleaned = await cleanupSqliteRoot( + root, + pending.result, + pending.input, + pending.output, + ); + if (cleaned.ok) this.#pendingCleanup.delete(root); + else failures.push(cleaned.error); + } + if (failures.length === 1 && failures[0] !== undefined) + return err(failures[0]); + return failures.length === 0 + ? ok(null) + : err( + new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + failures.flatMap((failure) => failure.cleanupResources), + { failures: failures.map(projectAnalysisError) }, + { operation }, + ), + ); + } + + async #inspect( + input: InspectSqliteDatabaseInput, + options?: ExecutionOptions, + ): Promise { + let root: Pick | undefined; + let processOwner: ProviderProcessSupervisor | undefined; + let retainedOutput: AnalysisCapturedOutput | undefined; + let phase = "configuration"; + let result: Result; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(operation); + validateSqliteInput(input); + const retired = await this.#retryCleanup(); + if (!retired.ok) return retired; + if (this.#closed || options?.signal?.aborted) + throw new AnalysisCancelledError(operation); + root = await this.createRuntime(); + phase = "artifact-read"; + const snapshot = await captureSqliteDatabaseSnapshot( + input.path, + root.path, + this.#artifactResources, + options?.signal, + ); + phase = "worker"; + const inspection = await executeSqliteDatabaseWorker({ + input, + root: root.path, + snapshotPath: snapshot.snapshotPath, + environment: this.environment, + resourceScope: this.#artifactResources, + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + ...(options?.signal === undefined ? {} : { signal: options.signal }), + captured: (output) => { + retainedOutput = output; + }, + }); + const validated = sqliteDatabaseSchema.safeParse({ + artifact: snapshot.artifact, + wal: snapshot.wal, + ...inspection, + }); + if (!validated.success) + throw new AnalysisOutputError( + operation, + "SQLite worker report dimensions or identity fields are invalid", + retainedOutput === undefined + ? undefined + : { capturedOutput: retainedOutput }, + ); + result = ok(createSqliteObservation(validated.data, input.path)); + } catch (cause: unknown) { + if (cause instanceof OwnedCommandFailure) + processOwner = cause.cleanupOwner; + const failure = sqliteDatabaseFailure( + cause, + input.path, + phase, + options?.signal, + ); + retainedOutput ??= failure.capturedOutput; + result = err(selectedFailure(failure, options?.signal, retainedOutput)); + } + if (root !== undefined) { + if (processOwner !== undefined) { + this.#pendingCleanup.set(root, { + input, + result, + output: retainedOutput, + process: processOwner, + }); + return result; + } + const cleaned = await cleanupSqliteRoot( + root, + result, + input, + retainedOutput, + ); + if (!cleaned.ok) { + this.#pendingCleanup.set(root, { + input, + result, + output: retainedOutput, + process: undefined, + }); + return cleaned; + } + } + // Completion was acknowledged before cleanup. A later abort does not erase it. + return result; + } +} + +const selectedFailure = ( + failure: AnalysisError, + signal?: AbortSignal, + output?: AnalysisCapturedOutput, +): AnalysisError => + signal?.aborted && + failure._tag !== "AnalysisCancelledError" && + !failure.cleanupIncomplete + ? new AnalysisCancelledError( + operation, + output === undefined ? undefined : { capturedOutput: output }, + ) + : failure; +const createSqliteObservation = ( + report: ReturnType, + path: string, +): AnalysisExecution => { + const locations: AnalysisExecution["locations"][number][] = [ + { kind: "artifact-path", path }, + ]; + if (report.wal !== null) + locations.push({ kind: "artifact-path", path: report.wal.path }); + return createAnalysisExecution( + report, + { ...SQLITE_PROVIDER_IDENTITY, version: `SQLite ${report.engine.version}` }, + { + rawResult: null, + subject: { path, format: "file", sha256: report.artifact.sha256 }, + locations, + limitations: report.limitations, + }, + ); +}; +const cleanupSqliteRoot = async ( + root: Pick, + result: Result, + input: InspectSqliteDatabaseInput, + output?: AnalysisCapturedOutput, +): Promise> => { + try { + await root.close(); + return ok(null); + } catch (cause: unknown) { + return err( + new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + previous_error: result.ok ? null : projectAnalysisError(result.error), + ...(output === undefined ? {} : { captured_output: { ...output } }), + }, + { + operation, + cause, + ...(result.ok + ? { + partialObservation: createEvidence( + result.value.subject ?? undefined, + result.value.provider, + { + operation, + parameters: sqliteInspectionParameters(input), + result: result.value.result, + confidence: "observed", + locations: result.value.locations, + limitations: result.value.limitations, + }, + ), + } + : result.error.partialObservation === undefined + ? {} + : { partialObservation: result.error.partialObservation }), + }, + ), + ); + } +}; + +const validateSqliteInput = (input: InspectSqliteDatabaseInput): void => { + const parsed = inspectSqliteDatabaseInputSchema.safeParse(input); + if (!parsed.success || !isAbsolute(input.path)) + throw new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: parsed.success + ? "path must be an absolute local filesystem path" + : parsed.error.message, + }, + ]); +}; diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts new file mode 100644 index 0000000000..4acf896113 --- /dev/null +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -0,0 +1,270 @@ +import { lstat, open, writeFile } from "node:fs/promises"; +import { constants, type BigIntStats } from "node:fs"; +import { join } from "node:path"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { + readStableArtifact, + type StableArtifactFileSystem, +} from "../artifacts/readStableArtifact.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open, +}; +const optionalStat = async ( + path: string, + selectedFileSystem: StableArtifactFileSystem, +): Promise => { + try { + return await selectedFileSystem.lstat(path); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return null; + throw cause; + } +}; +const sameState = ( + before: BigIntStats | null, + after: BigIntStats | null, +): boolean => + before === null || after === null + ? before === after + : before.dev === after.dev && + before.ino === after.ino && + before.mode === after.mode && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs; + +const duringAcquisition = async ( + path: string, + read: () => Promise, +): Promise => { + try { + return await read(); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${path}`, + { cause }, + ); + throw cause; + } +}; + +const readObservedArtifact = ( + path: string, + observed: BigIntStats, + maximumBytes: number, + selectedFileSystem: StableArtifactFileSystem, + resourceScope: ArtifactResourceScope, + signal?: AbortSignal, +) => + duringAcquisition(path, () => + readStableArtifact( + path, + maximumBytes, + signal, + { + open: (selectedPath, flags) => + selectedFileSystem.open(selectedPath, flags), + lstat: async (selectedPath) => { + const current = await selectedFileSystem.lstat(selectedPath); + if (!sameState(observed, current)) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${selectedPath}`, + ); + return current; + }, + }, + resourceScope, + ), + ); + +const verifyInactiveJournal = async ( + path: string, + before: BigIntStats, + selectedFileSystem: StableArtifactFileSystem, + resourceScope: ArtifactResourceScope, + phase: "initial" | "after-copy", + signal?: AbortSignal, +): Promise => { + if (!before.isFile() || before.isSymbolicLink()) + throw new ArtifactReaderFailure( + "path", + `Expected a regular journal without a symlink: ${path}`, + ); + const file = await duringAcquisition(path, () => + selectedFileSystem.open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ), + ); + const owner = new OwnedFileHandle(file); + let failure: unknown; + try { + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed before open: ${path}`, + ); + // TRUNCATE commits leave an empty file; PERSIST commits zero the complete + // 28-byte fixed header. A new active journal may zero only its magic bytes. + const header = Buffer.alloc(28); + let bytesRead = 0; + if (before.size > 0n) { + while (bytesRead < header.length) { + signal?.throwIfAborted(); + const read = await file.read( + header, + bytesRead, + header.length - bytesRead, + bytesRead, + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + } + if (!sameState(before, await file.stat({ bigint: true }))) + throw new ArtifactReaderFailure( + "integrity", + `SQLite journal changed during inspection: ${path}`, + ); + if ( + before.size !== 0n && + (bytesRead !== header.length || header.some((byte) => byte !== 0)) + ) + throw new ArtifactReaderFailure( + phase === "initial" ? "format" : "integrity", + phase === "initial" + ? `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.` + : `Rollback journal became active or malformed during database acquisition: ${path}`, + ); + } catch (cause: unknown) { + failure = cause; + throw cause; + } finally { + const cleanup = await resourceScope.release({ + kind: "file-handle", + resource: path, + handle: owner, + }); + if (cleanup.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + failure ?? cleanup.cause, + ArtifactReaderFailure.cleanupObservation(cleanup.cause, path), + ); + } +}; + +/** Freeze a stable offline file set without ever opening the source through SQLite. */ +export const captureSqliteDatabaseSnapshot = async ( + path: string, + root: string, + resourceScope: ArtifactResourceScope, + signal?: AbortSignal, + selectedFileSystem: StableArtifactFileSystem = fileSystem, +) => { + signal?.throwIfAborted(); + const paths = [path, `${path}-wal`, `${path}-journal`]; + const before = await Promise.all( + paths.map((path) => optionalStat(path, selectedFileSystem)), + ); + const database = before[0]; + const wal = before[1]; + if (database === undefined || wal === undefined || before[2] === undefined) + throw new Error("Incomplete database file-set observation"); + if (database === null) + throw new ArtifactReaderFailure( + "path", + `Selected SQLite database does not exist: ${path}`, + ); + if (before[2] !== null) + await verifyInactiveJournal( + `${path}-journal`, + before[2], + selectedFileSystem, + resourceScope, + "initial", + signal, + ); + const selectedSize = database.size + (wal?.size ?? 0n); + if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes)) + throw new ArtifactReaderFailure( + "limit", + `Selected SQLite database and WAL exceed the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte snapshot budget: ${path}`, + ); + const snapshotPath = join(root, "database.snapshot"); + const captured = await readObservedArtifact( + path, + database, + SQLITE_DATABASE_LIMITS.inputBytes, + selectedFileSystem, + resourceScope, + signal, + ); + await writeFile(snapshotPath, captured.bytes, { + flag: "wx", + mode: 0o600, + ...(signal === undefined ? {} : { signal }), + }); + const artifact = { + path, + sha256: captured.sha256, + bytes: captured.bytes.length, + }; + let walArtifact: typeof artifact | null = null; + if (wal !== null) { + const capturedWal = await readObservedArtifact( + `${path}-wal`, + wal, + SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes, + selectedFileSystem, + resourceScope, + signal, + ); + await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, { + flag: "wx", + mode: 0o600, + ...(signal === undefined ? {} : { signal }), + }); + walArtifact = { + path: `${path}-wal`, + sha256: capturedWal.sha256, + bytes: capturedWal.bytes.length, + }; + } + // Identity alone does not establish an inactive journal when timestamps are + // coarse. Re-read the header after copying, using the same validity owner. + if (before[2] !== null) + await verifyInactiveJournal( + `${path}-journal`, + before[2], + selectedFileSystem, + resourceScope, + "after-copy", + signal, + ); + const after = await Promise.all( + paths.map((path) => + duringAcquisition(path, () => optionalStat(path, selectedFileSystem)), + ), + ); + for (const [index, expected] of before.entries()) + if (!sameState(expected, after[index] ?? null)) + throw new ArtifactReaderFailure( + "integrity", + `SQLite snapshot file set changed during acquisition: ${paths[index] ?? path}`, + ); + signal?.throwIfAborted(); + return { snapshotPath, artifact, wal: walArtifact }; +}; diff --git a/src/sqlite/SqliteDatabaseWorker.ts b/src/sqlite/SqliteDatabaseWorker.ts new file mode 100644 index 0000000000..ebd2d5bac1 --- /dev/null +++ b/src/sqlite/SqliteDatabaseWorker.ts @@ -0,0 +1,57 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { z } from "zod"; +import { inspectSqliteDatabaseInputSchema } from "../domain/sqlite/sqliteDatabase.js"; +import { inspectSqliteDatabaseSnapshot } from "./SqliteDatabaseInspection.js"; +import { + SQLITE_DATABASE_LIMITS, + SqliteInspectionFailure, +} from "./SqliteDatabaseLimits.js"; + +const requestSchema = z.strictObject({ + snapshot_path: z.string(), + reply_path: z.string(), + input: inspectSqliteDatabaseInputSchema, +}); +const main = async (): Promise => { + const requestPath = process.argv[2]; + if (requestPath === undefined) + throw new Error("Owned SQLite worker request path missing."); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + let reply: unknown; + try { + reply = { + ok: true, + inspection: inspectSqliteDatabaseSnapshot( + request.snapshot_path, + request.input, + ), + }; + } catch (cause: unknown) { + const code = + cause instanceof Error && "errcode" in cause ? cause.errcode : undefined; + reply = { + ok: false, + reason: + cause instanceof SqliteInspectionFailure + ? cause.reason + : code === 7 + ? "memory" + : code === 18 + ? "output-limit" + : "format", + message: cause instanceof Error ? cause.message : String(cause), + }; + } + let encoded = JSON.stringify(reply); + if (Buffer.byteLength(encoded) > SQLITE_DATABASE_LIMITS.outputBytes) + encoded = JSON.stringify({ + ok: false, + reason: "output-limit", + message: + "Complete SQLite evidence exceeds the 16 MiB JSON reply budget; no partial success was returned.", + }); + await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 }); +}; +await main(); diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts new file mode 100644 index 0000000000..9474a6d27c --- /dev/null +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -0,0 +1,169 @@ +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { + sqliteDatabaseSchema, + type InspectSqliteDatabaseInput, +} from "../domain/sqlite/sqliteDatabase.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { runOwnedCommand } from "../process/OwnedCommand.js"; +import { capturedSqliteOutput } from "./SqliteDatabaseFailures.js"; +import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; + +const operation = "inspect_sqlite_database"; +const { + artifact: _artifactSchema, + wal: _walSchema, + ...inspectionShape +} = sqliteDatabaseSchema.shape; +const inspectionSchema = z.strictObject(inspectionShape); +const replySchema = z.discriminatedUnion("ok", [ + z.strictObject({ ok: z.literal(true), inspection: inspectionSchema }), + z.strictObject({ + ok: z.literal(false), + reason: z.enum([ + "format", + "selection", + "unsupported", + "unavailable", + "output-limit", + "memory", + ]), + message: z.string(), + }), +]); +/** Replaceable launcher retains the shared owned-process supervision boundary. */ +export type SqliteWorkerLauncher = NonNullable< + Parameters[2] +>["launcher"]; + +const rejectedReply = ( + reply: z.output & { ok: false }, + capturedOutput: AnalysisCapturedOutput, +) => { + if (reply.reason === "unavailable") + return new AnalysisCapabilityUnavailableError( + "sqlite", + operation, + reply.message, + { capturedOutput, userMessage: reply.message }, + ); + if (reply.reason === "memory") + return new AnalysisResourceConstraintError( + operation, + "memory", + reply.message, + { + javascript_heap_bytes: 256 * 1024 * 1024, + }, + { capturedOutput }, + ); + if (reply.reason === "output-limit") + return new AnalysisResourceConstraintError( + operation, + "transport", + reply.message, + { + sqlite_value_or_record_bytes: SQLITE_DATABASE_LIMITS.outputBytes, + worker_reply_bytes: SQLITE_DATABASE_LIMITS.outputBytes, + }, + { capturedOutput }, + ); + return new AnalysisInputError(operation, { capturedOutput }, [ + { + path: [reply.reason === "selection" ? "table" : "path"], + reason: "invalid_format", + message: reply.message, + }, + ]); +}; + +/** Execute only the private snapshot and validate the bounded worker protocol. */ +export const executeSqliteDatabaseWorker = async (request: { + readonly input: InspectSqliteDatabaseInput; + readonly root: string; + readonly snapshotPath: string; + readonly environment: Readonly; + readonly resourceScope: ArtifactResourceScope; + readonly launcher?: SqliteWorkerLauncher; + readonly signal?: AbortSignal; + readonly captured: (output: AnalysisCapturedOutput) => void; +}): Promise> => { + const requestPath = join(request.root, "request.json"); + const replyPath = join(request.root, "reply.json"); + await writeFile( + requestPath, + JSON.stringify({ + snapshot_path: request.snapshotPath, + reply_path: replyPath, + input: request.input, + }), + { flag: "wx", mode: 0o600 }, + ); + const { NODE_OPTIONS: _ambientNodeOptions, ...environment } = + request.environment; + const execution = await runOwnedCommand( + { + command: process.execPath, + arguments: [ + "--max-old-space-size=256", + fileURLToPath(new URL("./SqliteDatabaseWorker.js", import.meta.url)), + requestPath, + ], + cwd: request.root, + expectedCommand: null, + runId: `rea-sqlite-database-${randomUUID()}`, + hostEnvironment: environment, + }, + { + timeoutMs: SQLITE_DATABASE_LIMITS.timeoutMs, + diagnosticBytes: SQLITE_DATABASE_LIMITS.diagnosticBytes, + }, + { + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.launcher === undefined ? {} : { launcher: request.launcher }), + }, + ); + const capturedOutput = capturedSqliteOutput(execution); + request.captured(capturedOutput); + let reply: z.output; + try { + const file = await readStableArtifact( + replyPath, + SQLITE_DATABASE_LIMITS.outputBytes, + request.signal, + undefined, + request.resourceScope, + ); + reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + } catch (cause: unknown) { + const options = { + cause, + capturedOutput, + ...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), + }; + if (request.signal?.aborted) + throw new AnalysisCancelledError(operation, options); + throw new AnalysisOutputError( + operation, + `Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`, + options, + ); + } + if (!reply.ok) throw rejectedReply(reply, capturedOutput); + return reply.inspection; +}; diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts new file mode 100644 index 0000000000..d23c032a6e --- /dev/null +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -0,0 +1,597 @@ +import { createHash } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; +import { + expandProviderCapabilities, + expandToolAvailability, + providerCapabilitySet, + toolAvailabilityInventory, +} from "../../src/contracts/toolOutputSchemaPrimitives.js"; + +import { SQLITE_NATIVE_LIMITS_AVAILABLE } from "../fixtures/sqlite/database.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const digest = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "bounds default context and projects exact columns without evaluating unrelated cells", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-selective-"); + const path = join(root, "selected.db"); + const database = new DatabaseSync(path); + try { + database.exec(` + CREATE TABLE selected(id INTEGER, "" TEXT, payload BLOB GENERATED ALWAYS AS (zeroblob(72*1024*1024)) VIRTUAL); + CREATE TABLE unrelated(value TEXT); + CREATE VIEW unrelated_view AS SELECT value FROM unrelated; + CREATE INDEX selected_id ON selected(id); + CREATE TRIGGER selected_trigger AFTER INSERT ON selected BEGIN SELECT 1; END; + WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM numbers WHERE n<21) + INSERT INTO selected(id, "") SELECT n, 'retained' FROM numbers; + `); + } finally { + database.close(); + } + const before = await readFile(path); + const { call } = await connectLocalToolsMcp(); + const inventory = await call("inspect_sqlite_database", { path }); + expect(inventory.isError).not.toBe(true); + const schema = z + .object({ + normalized_result: z.object({ + schema: z.object({ + scope: z.literal("inventory"), + tables: z.array( + z.object({ + name: z.string(), + columns: z.array(z.unknown()), + columns_completeness: z.literal("not_requested"), + }), + ), + }), + }), + }) + .parse(inventory.structuredContent).normalized_result.schema; + expect(schema.tables.map((table) => table.name)).toEqual([ + "selected", + "unrelated", + ]); + expect(schema.tables.every((table) => table.columns.length === 0)).toBe( + true, + ); + expect(JSON.stringify(inventory.structuredContent)).not.toContain( + "zeroblob", + ); + const selected = await call("inspect_sqlite_database", { + path, + table: "selected", + columns: ["", "id", "id"], + }); + expect(selected.isError).not.toBe(true); + expect(selected.structuredContent).toMatchObject({ + normalized_result: { + schema: { + scope: "selected_table", + tables: [{ name: "selected" }], + views: [], + indexes: [{ name: "selected_id" }], + triggers: [{ name: "selected_trigger" }], + }, + rows: { + columns: ["", "id", "id"], + row_limit: 20, + returned_rows: 20, + truncated: true, + values: expect.arrayContaining([ + [ + { + type: "text", + value: "retained", + bytes_hex: "72657461696e6564", + encoding: "UTF-8", + }, + { type: "integer", value: "1" }, + { type: "integer", value: "1" }, + ], + ]), + }, + }, + }); + const cliResult = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "selected", + "--column", + "id", + "--column", + "", + "--row-limit", + "1", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(cliResult.exitCode, cliResult.stdout + cliResult.stderr).toBe(0); + expect(cliResult.json).toMatchObject({ + normalized_result: { rows: { columns: ["id", ""], returned_rows: 1 } }, + }); + for (const input of [ + { path, columns: ["id"] }, + { path, schema_scope: "selected_table" }, + { path, table: "selected", columns: [] }, + { path, table: "selected", columns: ["ID"] }, + ]) + expect((await call("inspect_sqlite_database", input)).isError).toBe(true); + expect(await readFile(path)).toEqual(before); + }, +); + +cliTest( + "discovers SQLite runtime availability before inspecting a database through CLI and MCP", + async ({ cli }) => { + const result = await cli.run({ + arguments: ["capabilities", "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + const status = z + .object({ + open: z.literal(false), + capabilities: z.array(providerCapabilitySet), + }) + .parse(result.json); + expect(expandProviderCapabilities(status.capabilities)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "inspect_sqlite_database", + available: SQLITE_NATIVE_LIMITS_AVAILABLE, + reason: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + availability_code: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : "unsupported_version", + limitations: [expect.stringContaining("DatabaseSync.limits")], + effects: { + mutates_artifact: false, + launches_process: true, + may_show_ui: false, + may_access_network: false, + may_write_filesystem: true, + changes_permissions: false, + requires_root: false, + }, + }), + ]), + ); + const { call } = await connectLocalToolsMcp(); + const response = await call("binary_session", {}); + expect(response.isError).not.toBe(true); + const sessionStatus = z + .object({ + result: z.object({ tool_availability: toolAvailabilityInventory }), + }) + .parse(response.structuredContent); + expect( + expandToolAvailability(sessionStatus.result.tool_availability), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "inspect_sqlite_database", + available: SQLITE_NATIVE_LIMITS_AVAILABLE, + remediation: SQLITE_NATIVE_LIMITS_AVAILABLE + ? null + : expect.stringContaining("Node.js 24.x >=24.15 or 26+"), + }), + ]), + ); + }, +); + +cliTest.skipIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "reports the required native SQLite limits capability through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-unavailable-"); + const path = join(root, "selected.db"); + const database = new DatabaseSync(path); + try { + database.exec("CREATE TABLE items(value INTEGER)"); + } finally { + database.close(); + } + const result = await cli.run({ + arguments: ["inspect-sqlite-database", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("DatabaseSync.limits"), + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_sqlite_database", { path }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("DatabaseSync.limits"), + }); + }, +); + +cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "accepts small samples above 1000 rows within the real resource budgets", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-row-budget-"); + const path = join(root, "rows.db"); + const database = new DatabaseSync(path); + try { + database.exec(`CREATE TABLE items(value INTEGER); + WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers WHERE value < 1002) + INSERT INTO items SELECT value FROM numbers;`); + } finally { + database.close(); + } + const { call } = await connectLocalToolsMcp(); + for (const limit of [1001, Number.MAX_SAFE_INTEGER]) { + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "items", + "--row-limit", + String(limit), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + rows: { + row_limit: limit, + returned_rows: Math.min(limit, 1002), + truncated: limit < 1002, + }, + }, + }); + const response = await call("inspect_sqlite_database", { + path, + table: "items", + row_limit: limit, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + } + }, +); + +const sourceState = async (path: string) => { + const bytes = await readFile(path); + const metadata = await stat(path, { bigint: true }); + return { + bytes, + device: metadata.dev, + inode: metadata.ino, + size: metadata.size, + modified: metadata.mtimeNs, + changed: metadata.ctimeNs, + }; +}; + +const createLosslessSnapshot = (path: string, journalMode: string): void => { + const database = new DatabaseSync(path); + try { + database.exec(`PRAGMA journal_mode=${journalMode}`); + database.exec(` + CREATE TABLE "odd "" table" ( + id INTEGER PRIMARY KEY, + payload BLOB, + amount REAL, + missing TEXT, + text_value TEXT, + negative_zero, + generated TEXT GENERATED ALWAYS AS (hex(payload)) VIRTUAL + ); + CREATE UNIQUE INDEX "quoted "" index" ON "odd "" table"(payload, id); + CREATE VIEW readable AS SELECT id FROM "odd "" table"; + CREATE TRIGGER observed AFTER INSERT ON "odd "" table" BEGIN SELECT 1; END; + CREATE TABLE keyed(k TEXT PRIMARY KEY) WITHOUT ROWID, STRICT; + `); + const insert = database.prepare( + 'INSERT INTO "odd "" table"(id,payload,amount,missing,text_value,negative_zero) VALUES(?,?,?,NULL,CAST(X\'80ff\' AS TEXT),?)', + ); + insert.setReadBigInts(true); + expect( + insert.run( + 9007199254740993n, + Buffer.from("deadbeef", "hex"), + Infinity, + -0, + ).lastInsertRowid, + ).toBe(9007199254740993n); + insert.run(9007199254740994n, Buffer.from("deadbeef", "hex"), Infinity, -0); + } finally { + database.close(); + } +}; + +cliTest + .runIf(SQLITE_NATIVE_LIMITS_AVAILABLE) + .for(["DELETE", "PERSIST", "TRUNCATE"])( + "inspects a real %s SQLite schema and lossless selected records identically through CLI and MCP", + async (journalMode, { cli }) => { + const root = await createTestTempDirectory("rea-sqlite-public-"); + const path = join(root, "selected.db"); + createLosslessSnapshot(path, journalMode); + const original = await readFile(path); + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + "selected.db", + "--schema-scope", + "database", + "--table", + 'odd " table', + "--row-limit", + "1", + "--json", + ], + cwd: root, + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_GHIDRA_HOME: "/unconfigured/ghidra", + }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + operation: "inspect_sqlite_database", + confidence: "observed", + subject: { local_path: path, digest: { sha256: digest(original) } }, + normalized_result: { + artifact: { path, sha256: digest(original), bytes: original.length }, + wal: null, + schema: { + completeness: "complete", + tables: expect.arrayContaining([ + expect.objectContaining({ + name: 'odd " table', + columns: expect.arrayContaining([ + expect.objectContaining({ + name: "id", + primary_key_position: 1, + }), + expect.objectContaining({ name: "generated", hidden: 2 }), + ]), + }), + expect.objectContaining({ + name: "keyed", + without_rowid: true, + strict: true, + }), + ]), + indexes: expect.arrayContaining([ + expect.objectContaining({ + name: 'quoted " index', + table: 'odd " table', + }), + ]), + views: [ + { name: "readable", sql: expect.stringContaining("SELECT id") }, + ], + triggers: [ + { + name: "observed", + table: 'odd " table', + sql: expect.stringContaining("AFTER INSERT"), + }, + ], + }, + rows: { + table: 'odd " table', + columns: [ + "id", + "payload", + "amount", + "missing", + "text_value", + "negative_zero", + "generated", + ], + returned_rows: 1, + row_limit: 1, + truncated: true, + values: [ + [ + { + type: "integer", + value: expect.stringMatching(/^900719925474099[34]$/), + }, + { type: "blob", hex: "deadbeef" }, + { type: "real", value: "Infinity" }, + { type: "null" }, + { type: "text", value: "\ufffd\ufffd", bytes_hex: "80ff" }, + { type: "real", value: "-0" }, + { + type: "text", + value: "DEADBEEF", + bytes_hex: "4445414442454546", + }, + ], + ], + }, + }, + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_sqlite_database", { + path, + table: 'odd " table', + schema_scope: "database", + row_limit: 1, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + expect(await readFile(path)).toEqual(original); + }, +); + +cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "includes uncheckpointed committed WAL data without modifying the source database or sidecars", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-wal-public-"); + const path = join(root, "wal.db"); + const database = new DatabaseSync(path); + onTestFinished(() => database.close()); + database.exec( + "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE committed(value TEXT); INSERT INTO committed VALUES('only in WAL');", + ); + const files = [path, `${path}-wal`, `${path}-shm`]; + const before = await Promise.all(files.map(sourceState)); + const db = before[0]; + const wal = before[1]; + expect(db).toBeDefined(); + expect(wal).toBeDefined(); + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "committed", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + artifact: { sha256: db === undefined ? "missing" : digest(db.bytes) }, + wal: { + path: `${path}-wal`, + sha256: wal === undefined ? "missing" : digest(wal.bytes), + }, + rows: { + values: [ + [ + { + type: "text", + value: "only in WAL", + bytes_hex: Buffer.from("only in WAL").toString("hex"), + }, + ], + ], + returned_rows: 1, + truncated: false, + }, + }, + }); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_sqlite_database", { + path, + table: "committed", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + const after = await Promise.all(files.map(sourceState)); + expect(after).toEqual(before); + }, +); + +cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "returns actionable public errors for corrupt input, unknown or ambiguous tables and invalid record choices", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-errors-public-"); + const path = join(root, "invalid.db"); + await writeFile(path, "not a SQLite database"); + const { call } = await connectLocalToolsMcp(); + const corrupt = await cli.run({ + arguments: ["inspect-sqlite-database", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(corrupt.exitCode).toBe(1); + expect(corrupt.json).toMatchObject({ code: "invalid_request" }); + expect((await call("inspect_sqlite_database", { path })).isError).toBe( + true, + ); + const valid = join(root, "valid.db"); + const database = new DatabaseSync(valid); + try { + database.exec( + "CREATE TABLE present(value TEXT); CREATE VIRTUAL TABLE search USING fts5(body);", + ); + } finally { + database.close(); + } + const bytes = await readFile(valid); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) throw new Error("Virtual declaration missing"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(valid, bytes); + const schema = await call("inspect_sqlite_database", { path: valid }); + expect(schema.isError).not.toBe(true); + expect(schema.structuredContent).toMatchObject({ + normalized_result: { + schema: { + tables: expect.arrayContaining([ + expect.objectContaining({ name: "search_data", kind: "unknown" }), + ]), + }, + }, + }); + const ambiguous = await cli.run({ + arguments: [ + "inspect-sqlite-database", + valid, + "--table", + "search_data", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(ambiguous.exitCode).toBe(1); + expect(ambiguous.stdout).toContain("shadow table"); + expect( + ( + await call("inspect_sqlite_database", { + path: valid, + table: "search_data", + }) + ).isError, + ).toBe(true); + const missing = await cli.run({ + arguments: [ + "inspect-sqlite-database", + valid, + "--table", + "absent", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(missing.exitCode).toBe(1); + expect(missing.stdout).toContain("absent"); + expect( + (await call("inspect_sqlite_database", { path: valid, table: "absent" })) + .isError, + ).toBe(true); + for (const input of [ + { path: valid, row_limit: 1 }, + { path: valid, table: "present", row_limit: 0 }, + { path: valid, query: "DELETE FROM present" }, + ]) { + expect( + (await call("inspect_sqlite_database", input)).isError, + JSON.stringify(input), + ).toBe(true); + } + }, +); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index f1b16a52c6..9176dec349 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -442,6 +442,10 @@ it("rejects skipped selected work while allowing unselected lanes to skip", asyn }); it.each([ + [ + ".github/workflows/sqlite-runtime.yml", + { workflows: true, code: true, package: false, website: false }, + ], [ ".github/workflows/real-termux.yml", { workflows: true, code: false, package: false, website: false }, diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts new file mode 100644 index 0000000000..4cff44e968 --- /dev/null +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; +import { executeWorkflowFixture } from "../../support/workflowGit.js"; + +const workflow = async (filename = "ci.yml") => + z + .object({ + jobs: z.record( + z.string(), + z + .object({ + needs: z.union([z.string(), z.array(z.string())]).optional(), + }) + .passthrough(), + ), + }) + .parse( + parse( + await readFile( + new URL(`../../../.github/workflows/${filename}`, import.meta.url), + "utf8", + ), + ), + ); + +it("binds SQLite compatibility jobs to the build's prepared runtime artifact and root layout", async () => { + const steps = z.array( + z.object({ + uses: z.string().optional(), + with: z + .object({ name: z.string().optional(), path: z.string().optional() }) + .optional(), + }), + ); + const uploads = steps.parse((await workflow()).jobs.build?.steps).filter( + (step) => + step.uses?.startsWith("actions/upload-artifact@") && + step.with?.path + ?.split(/\r?\n/u) + .map((path) => path.trim()) + .includes("dist/"), + ); + expect(uploads).toHaveLength(1); + const name = z.string().min(1).parse(uploads[0]?.with?.name); + const downloads = steps + .parse((await workflow("sqlite-runtime.yml")).jobs.sqlite?.steps) + .filter((step) => step.uses?.startsWith("actions/download-artifact@")); + expect(downloads).toHaveLength(1); + expect(downloads[0]?.with).toEqual({ name, path: "." }); +}); + +const requiredNeeds = async () => { + const { jobs } = await workflow(); + return { jobs, needs: z.array(z.string()).parse(jobs.required?.needs) }; +}; + +it("makes the required gate wait for every CI lane, including future additions", async () => { + const { jobs, needs } = await requiredNeeds(); + expect(new Set(needs)).toEqual( + new Set(Object.keys(jobs).filter((name) => name !== "required")), + ); +}); + +it.each([ + ["true", "success", true], + ["true", "failure", false], + ["true", "cancelled", false], + ["true", "skipped", false], + ["true", "missing", false], + ["false", "skipped", true], +] as const)( + "enforces the SQLite required gate for code=%s and result=%s", + async (code, result, passes) => { + const workflow = await requiredNeeds(); + // GitHub exposes only direct dependencies in the required job's needs context. + const needs: Record< + string, + { result: string; outputs?: Record } + > = Object.fromEntries( + workflow.needs.map((name) => [name, { result: "success" }]), + ); + needs.changes = { result: "success", outputs: { code } }; + if (result === "missing") delete needs["sqlite-runtime"]; + else if ("sqlite-runtime" in needs) needs["sqlite-runtime"] = { result }; + const execution = executeWorkflowFixture( + process.execPath, + [ + fileURLToPath( + new URL("../../../scripts/ci/required.mjs", import.meta.url), + ), + ], + { env: { ...process.env, CI_JOBS: JSON.stringify(needs) } }, + ); + if (passes) await expect(execution).resolves.toMatchObject({ stderr: "" }); + else + await expect(execution).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("sqlite-runtime"), + }); + }, +); diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts index 6a140befe7..c841e08ede 100644 --- a/tests/boundary/mcp/serverLifecycleMcp.test.ts +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -8,9 +8,332 @@ import { HopperRemoteError } from "../../../src/domain/hopperErrors.js"; import { err } from "../../../src/domain/result.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import { createServer } from "../../../src/server/createServer.js"; +import { SqliteDatabaseService } from "../../../src/application/sqlite/SqliteDatabaseService.js"; +import { PassThrough } from "node:stream"; +import { once } from "node:events"; +import { access } from "node:fs/promises"; +import { + serveStdio, + StdioServerTransport, +} from "@modelcontextprotocol/server/stdio"; +import pino from "pino"; +import { createManagedBinarySession } from "../../../src/composition/binary.js"; +import { startMcpTransport } from "../../../src/main/transport.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { fileURLToPath } from "node:url"; +import { + createSqliteDatabaseFixture, + SQLITE_NATIVE_LIMITS_AVAILABLE, +} from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; +import { z } from "zod"; +import { createDeferred } from "../../fixtures/binarySession.js"; const resources: Array<{ close(): Promise }> = []; +it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "drains an admitted SQLite inspection before changing the session binding", + async () => { + const workspace = await createTestWorkspace("rea-sqlite-mcp-admission-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + const acquired = createDeferred(); + const release = createDeferred(); + const provider = new SqliteDatabaseProvider( + process.env, + (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 + ? fileURLToPath( + new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, + ), + ) + : argument, + ), + }), + async () => { + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + }); + acquired.resolve(); + await release.promise; + return root; + }, + ); + const session = createManagedBinarySession(); + const server = createServer( + { kind: "session", session }, + { sqliteDatabase: new SqliteDatabaseService(provider) }, + ); + const client = new Client({ name: "sqlite-admission", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const inspection = client.callTool({ + name: "inspect_sqlite_database", + arguments: { path: database.path, table: "records", columns: ["id"] }, + }); + await acquired.promise; + let closed = false; + const closing = session.close().then((result) => { + closed = true; + return result; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(closed).toBe(false); + release.resolve(); + const result = await inspection; + expect(result.isError).not.toBe(true); + const evidenceId = z + .object({ evidence_id: z.string() }) + .parse(result.structuredContent).evidence_id; + expect(result.structuredContent).toMatchObject({ + normalized_result: { rows: { columns: ["id"], returned_rows: 3 } }, + }); + expect(await closing).toMatchObject({ ok: true }); + // Closing a binding clears its ledger after the admitted recording succeeds. + expect(session.evidenceById(evidenceId)).toBeUndefined(); + expect(closed).toBe(true); + } finally { + release.resolve(); + await client.close(); + await server.close(); + await session.close(); + await removeTestWorkspace(workspace.root); + } + }, +); + +it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "records completed SQLite evidence before delivering a cleanup failure", + async () => { + const workspace = await createTestWorkspace("rea-sqlite-mcp-retention-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + let removalAllowed = false; + const provider = new SqliteDatabaseProvider( + process.env, + (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 + ? fileURLToPath( + new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, + ), + ) + : argument, + ), + }), + async () => { + const root = await PrivateRuntimeRoot.create({ + parent: workspace.root, + }); + return { + path: root.path, + close: async () => { + if (!removalAllowed) + throw new Error( + "Snapshot cleanup denied after completed observation", + ); + await root.close(); + }, + }; + }, + ); + const session = createManagedBinarySession(); + const server = createServer( + { kind: "session", session }, + { sqliteDatabase: new SqliteDatabaseService(provider) }, + ); + const client = new Client({ + name: "sqlite-retained-observation", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "inspect_sqlite_database", + arguments: { path: database.path, table: "records", columns: ["id"] }, + }); + expect(result.isError).toBe(true); + const error = z + .object({ + error: z.object({ + details: z.object({ + partial_observation: z.object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }), + }), + }), + }) + .parse(parseMcpToolError(result)).error; + const retained = session.evidenceById( + error.details.partial_observation.evidence_id, + ); + expect(retained).toMatchObject({ + operation: "inspect_sqlite_database", + parameters: { columns: ["id"] }, + normalized_result: { rows: { columns: ["id"], returned_rows: 3 } }, + }); + expect(JSON.stringify(parseMcpToolError(result))).not.toContain( + "normalized_result", + ); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.isError).not.toBe(true); + expect(JSON.stringify(bundle.structuredContent)).toContain( + error.details.partial_observation.evidence_id, + ); + } finally { + removalAllowed = true; + await client.close(); + await server.close(); + await session.close(); + await removeTestWorkspace(workspace.root); + } + }, +); + +it.each(["handle", "stdin"] as const)( + "retains failed SQLite cleanup through production stdio shutdown: %s", + async (shutdown) => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-sqlite-stdio-cleanup-", + }); + let removalAllowed = false; + let attempts = 0; + const sqliteDatabase = new SqliteDatabaseService({ + inspect: async () => { + throw new Error("No inspection requested"); + }, + close: async () => { + attempts += 1; + if (!removalAllowed) throw new Error("SQLite snapshot removal denied"); + await root.close(); + }, + }); + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const logger = pino({ enabled: false }); + const session = createManagedBinarySession(); + const started = await startMcpTransport( + { + env: {}, + serve: (factory, options) => + serveStdio(factory, { + ...options, + transport: new StdioServerTransport(stdin, stdout), + }), + createServer: (source, options) => + createServer(source, { ...options, sqliteDatabase }), + writeStderr: () => undefined, + setExitCode: () => undefined, + registerShutdown: () => () => undefined, + }, + session, + { + environment: {}, + delivery: createToolResultDelivery(undefined), + logger, + serverLogger: logger, + loadOptionalProviders: async () => ({}), + }, + ); + if (!started.ok) throw new Error("Transport startup failed"); + try { + const response = once(stdout, "data"); + stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "sqlite-cleanup-test", version: "1.0.0" }, + }, + })}\n`, + ); + const [reply] = await response; + expect(JSON.parse(String(reply))).toMatchObject({ + id: 1, + result: { serverInfo: { name: "rea" } }, + }); + if (shutdown === "stdin") { + stdin.end(); + await new Promise((resolve) => setImmediate(resolve)); + } + await expect(started.handle.close()).rejects.toBeInstanceOf( + AggregateError, + ); + expect(attempts).toBeGreaterThan(0); + await expect(access(root.path)).resolves.toBeUndefined(); + removalAllowed = true; + await started.handle.close(); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + const completedAttempts = attempts; + await started.handle.close(); + expect(attempts).toBe(completedAttempts); + } finally { + removalAllowed = true; + await started.handle.close(); + await started.closeAndroid(); + await session.close(); + await root.close(); + stdin.destroy(); + stdout.destroy(); + } + }, +); + +it("awaits SQLite cleanup and retries its retained owner after failed server shutdown", async () => { + let removalAllowed = false; + let attempts = 0; + const sqliteDatabase = new SqliteDatabaseService({ + inspect: async () => { + throw new Error("No inspection requested"); + }, + close: async () => { + attempts += 1; + if (!removalAllowed) throw new Error("SQLite snapshot removal denied"); + }, + }); + const server = createServer( + { kind: "fixed", analysis: { execute: async () => ok(null) } }, + { sqliteDatabase }, + ); + resources.push(server); + try { + await expect(server.close()).rejects.toBeInstanceOf(AggregateError); + expect(attempts).toBe(1); + } finally { + removalAllowed = true; + } + await server.close(); + expect(attempts).toBe(2); +}); + afterEach(async () => { await Promise.all( resources.splice(0).map(async (resource) => resource.close()), diff --git a/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts new file mode 100644 index 0000000000..de8e142980 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseCleanup.test.ts @@ -0,0 +1,275 @@ +import { access } from "node:fs/promises"; +import { expect, it, onTestFinished } from "vitest"; +import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; +import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js"; +import { + spawnProviderProcessFixture, + stopProviderProcessFixture, + waitForProviderProcessReady, +} from "../../fixtures/providerProcess.js"; +import { createDeferred } from "../../fixtures/binarySession.js"; +import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const createUnavailableProvider = ( + createRuntime: SqliteDatabaseProvider["createRuntime"], +) => + new SqliteDatabaseProvider( + {}, + () => { + throw new Error("SQLite worker deliberately unavailable"); + }, + createRuntime, + ); + +const cleanupFixture = async () => { + const workspace = await createTestWorkspace("rea-sqlite-cleanup-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + const roots: PrivateRuntimeRoot[] = []; + let removalAllowed = false; + let attempts = 0; + const provider = createUnavailableProvider(async () => { + const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + roots.push(root); + return { + path: root.path, + close: async () => { + attempts += 1; + if (!removalAllowed) throw new Error("snapshot removal denied"); + await root.close(); + }, + }; + }); + onTestFinished(async () => { + await Promise.all(roots.map((root) => root.close())); + await removeTestWorkspace(workspace.root); + }); + return { + provider, + roots, + path: database.path, + allowRemoval: () => { + removalAllowed = true; + }, + attempts: () => attempts, + }; +}; + +it("retains failed snapshot owners and blocks new acquisition until retry succeeds", async () => { + const fixture = await cleanupFixture(); + const first = await fixture.provider.inspect({ path: fixture.path }); + expect(first).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + const root = fixture.roots[0]; + if (root === undefined) throw new Error("Missing owned root"); + await expect(access(root.path)).resolves.toBeUndefined(); + const second = await fixture.provider.inspect({ path: fixture.path }); + expect(second).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(fixture.roots).toHaveLength(1); + expect(fixture.attempts()).toBe(2); + fixture.allowRemoval(); + await fixture.provider.inspect({ path: fixture.path }); + expect(fixture.roots).toHaveLength(2); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + await fixture.provider.close(); +}); + +it("awaits shared shutdown cleanup and retains failures for a later close", async () => { + const fixture = await cleanupFixture(); + await fixture.provider.inspect({ path: fixture.path }); + const closed = await Promise.allSettled([ + fixture.provider.close(), + fixture.provider.close(), + ]); + expect(closed.map((result) => result.status)).toEqual([ + "rejected", + "rejected", + ]); + expect(fixture.attempts()).toBe(2); + expect(await fixture.provider.inspect({ path: fixture.path })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + fixture.allowRemoval(); + await fixture.provider.close(); + await fixture.provider.close(); + expect(fixture.attempts()).toBe(3); + for (const root of fixture.roots) + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("waits for concurrent acquisitions and releases only owners whose cleanup succeeds", async () => { + const workspace = await createTestWorkspace("rea-sqlite-concurrent-cleanup-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + const acquired = createDeferred(); + const release = createDeferred(); + const roots: PrivateRuntimeRoot[] = []; + const allowed = new Set(); + const provider = createUnavailableProvider(async () => { + const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + roots.push(root); + if (roots.length === 2) acquired.resolve(); + await release.promise; + return { + path: root.path, + close: async () => { + if (!allowed.has(root)) throw new Error("snapshot removal denied"); + await root.close(); + }, + }; + }); + onTestFinished(async () => { + release.resolve(); + roots.forEach((root) => allowed.add(root)); + await provider.close(); + await removeTestWorkspace(workspace.root); + }); + const inspections = [ + provider.inspect({ path: database.path }), + provider.inspect({ path: database.path }), + ]; + await acquired.promise; + // Two admitted snapshots occupy capacity even before the worker launches. + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError", resource: "capacity" }, + }); + expect(roots).toHaveLength(2); + expect( + await provider.inspect( + { path: database.path }, + { signal: AbortSignal.abort() }, + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + let settled = false; + const closing = provider.close().finally(() => { + settled = true; + }); + const rejected = expect(closing).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + release.resolve(); + await Promise.all(inspections); + await rejected; + const [first, second] = roots; + if (first === undefined || second === undefined) + throw new Error("Missing owned roots"); + allowed.add(first); + await expect(provider.close()).rejects.toMatchObject({ + cleanupResources: [second.path], + }); + await expect(access(first.path)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(second.path)).resolves.toBeUndefined(); + allowed.add(second); + await provider.close(); + await expect(access(second.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("retains the snapshot until a failed worker cleanup owner can be stopped", async () => { + const workspace = await createTestWorkspace("rea-sqlite-worker-cleanup-"); + const database = createSqliteDatabaseFixture(workspace.root); + database.close(); + const child = spawnProviderProcessFixture("graceful"); + let workerRemovalAllowed = false; + let rootRemovalAllowed = false; + let workerAttempts = 0; + let rootAttempts = 0; + const roots: PrivateRuntimeRoot[] = []; + const supervisor = new ProviderProcessSupervisor({ + process: child, + ownsProcessLifetime: true, + cleanup: async () => { + workerAttempts += 1; + if (!workerRemovalAllowed) + return { cleaned: false, reason: "SQLite worker cleanup denied" }; + await stopProviderProcessFixture(child); + return { cleaned: true, signaled: true }; + }, + }); + const provider = new SqliteDatabaseProvider( + {}, + async (spawn) => { + const stopped = await supervisor.stop(); + if (stopped.status !== "incomplete") + throw new Error("Expected retained worker"); + throw new OwnedCommandFailure( + "process", + "SQLite worker cleanup failed", + supervisor.snapshot(), + stopped.reason, + undefined, + [spawn.runId, spawn.cwd ?? ""], + supervisor, + ); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + roots.push(root); + return { + path: root.path, + close: async () => { + rootAttempts += 1; + if (!rootRemovalAllowed) + throw new Error("SQLite snapshot removal denied"); + await root.close(); + }, + }; + }, + ); + onTestFinished(async () => { + workerRemovalAllowed = true; + rootRemovalAllowed = true; + await supervisor.stop(); + await provider.close(); + await Promise.all(roots.map((root) => root.close())); + await removeTestWorkspace(workspace.root); + }); + await waitForProviderProcessReady(child); + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + // The snapshot cannot be removed while its worker may still use it. + expect(rootAttempts).toBe(0); + const root = roots[0]; + if (root === undefined) throw new Error("Missing retained snapshot"); + await expect(access(root.path)).resolves.toBeUndefined(); + expect(await provider.inspect({ path: database.path })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(roots).toHaveLength(1); + expect(workerAttempts).toBe(2); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(rootAttempts).toBe(0); + workerRemovalAllowed = true; + await expect(provider.close()).rejects.toMatchObject({ + cleanupResources: [root.path], + }); + expect(child.signalCode).toBe("SIGKILL"); + expect(workerAttempts).toBe(4); + expect(rootAttempts).toBe(1); + rootRemovalAllowed = true; + await provider.close(); + expect(workerAttempts).toBe(4); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts new file mode 100644 index 0000000000..f656c35f48 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseDescriptorCleanup.test.ts @@ -0,0 +1,378 @@ +import { Cli } from "incur"; +import { access, writeFile } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { SqliteDatabaseService } from "../../../src/application/sqlite/SqliteDatabaseService.js"; +import { registerSqliteCommands } from "../../../src/cli/sqliteCommands.js"; +import { createSqliteDatabaseService } from "../../../src/composition/sqlite.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { silentLogger } from "../../../src/logger.js"; +import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { createSqliteDatabaseFixture } from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const descriptorCleanup = vi.hoisted(() => ({ + path: "", + denied: false, + invalidate: false, + attempts: 0, + handles: [] as FileHandle[], + readFailure: undefined as Error | undefined, + onOpen: undefined as (() => void) | undefined, +})); + +vi.mock("../../../src/composition/sqlite.js", () => ({ + createSqliteDatabaseService: vi.fn(), +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (args[0] === descriptorCleanup.path) { + descriptorCleanup.handles.push(handle); + const close = handle.close.bind(handle); + handle.close = async () => { + // FileHandle.close also asks attached streams to close the invalidated handle. + if (handle.fd < 0) { + await close(); + return; + } + descriptorCleanup.attempts += 1; + if (descriptorCleanup.denied) { + if (descriptorCleanup.invalidate) await close(); + throw new Error("injected SQLite descriptor close failure"); + } + await close(); + }; + if (descriptorCleanup.readFailure !== undefined) + vi.spyOn(handle, "stat").mockRejectedValue( + descriptorCleanup.readFailure, + ); + descriptorCleanup.onOpen?.(); + } + return handle; + }, + }; +}); + +const fixture = async ( + member: "database" | "WAL" | "journal" | "reply", + invalidate = false, +) => { + const workspace = await createTestWorkspace("rea-sqlite-descriptor-cleanup-"); + const database = createSqliteDatabaseFixture(workspace.root, true); + const path = + member === "database" + ? database.path + : member === "WAL" + ? database.walPath + : member === "journal" + ? `${database.path}-journal` + : ""; + if (member === "journal") await writeFile(path, Buffer.alloc(28)); + const roots: PrivateRuntimeRoot[] = []; + const provider = new SqliteDatabaseProvider( + process.env, + (spawn) => { + if (member !== "reply") + throw new Error("SQLite worker deliberately unavailable"); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 + ? fileURLToPath( + new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, + ), + ) + : argument, + ), + }); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + if (member === "reply") + descriptorCleanup.path = join(root.path, "reply.json"); + roots.push(root); + return root; + }, + ); + descriptorCleanup.path = path; + descriptorCleanup.denied = true; + descriptorCleanup.invalidate = invalidate; + descriptorCleanup.attempts = 0; + descriptorCleanup.handles = []; + descriptorCleanup.readFailure = undefined; + descriptorCleanup.onOpen = undefined; + onTestFinished(async () => { + descriptorCleanup.denied = false; + try { + await provider.close(); + } catch (cause) { + if (!invalidate) throw cause; + } + for (const handle of descriptorCleanup.handles) await handle.close(); + descriptorCleanup.path = ""; + database.close(); + await removeTestWorkspace(workspace.root); + }); + return { provider, roots, path, database }; +}; + +const serveInspection = async ( + provider: SqliteDatabaseProvider, + path: string, +) => { + const previousExitCode = process.exitCode; + onTestFinished(() => { + process.exitCode = previousExitCode; + }); + vi.mocked(createSqliteDatabaseService).mockReturnValue( + new SqliteDatabaseService(provider), + ); + const cli = Cli.create("rea-sqlite-cleanup-test", { sync: false }); + registerSqliteCommands(cli, silentLogger, {}); + let stdout = ""; + await cli.serve(["inspect-sqlite-database", path, "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }); + return { output: JSON.parse(stdout) as unknown, exitCode: process.exitCode }; +}; + +it.each(["database", "WAL", "journal"] as const)( + "retains a failed %s descriptor and retries it at shutdown", + async (member) => { + const selected = await fixture(member); + const first = await selected.provider.inspect({ + path: selected.database.path, + }); + expect(first).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [expect.stringContaining(selected.path)], + }, + }); + const handle = descriptorCleanup.handles[0]; + if (handle === undefined) + throw new Error("Expected owned source descriptor"); + expect(handle.fd).toBeGreaterThanOrEqual(0); + const blocked = await selected.provider.inspect({ + path: selected.database.path, + }); + expect(blocked).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(selected.roots).toHaveLength(1); + await expect(selected.provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(descriptorCleanup.attempts).toBe(3); + descriptorCleanup.denied = false; + await selected.provider.close(); + expect(descriptorCleanup.attempts).toBe(4); + expect(handle.fd).toBe(-1); + for (const root of selected.roots) + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.each(["EACCES", "EPERM"])( + "retains the original %s inspection diagnostics through CLI shutdown", + async (code) => { + const selected = await fixture("database"); + descriptorCleanup.readFailure = Object.assign( + new Error("injected source read denial"), + { code }, + ); + const result = await serveInspection( + selected.provider, + selected.database.path, + ); + expect(result).toMatchObject({ + exitCode: 1, + output: { + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining(selected.path)], + diagnostics: { + primary_error: { + details: { + diagnostics: { + previous_error: { + code: "access_denied", + details: { path: selected.path, system_code: code }, + }, + }, + }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }, + }); + expect(descriptorCleanup.attempts).toBe(2); + }, +); + +it("retains cancellation diagnostics and the interrupt exit code through CLI shutdown", async () => { + const selected = await fixture("database"); + descriptorCleanup.onOpen = () => { + const interrupt = process.listeners("SIGINT").at(-1); + if (interrupt === undefined) + throw new Error("Missing CLI interrupt handler"); + interrupt("SIGINT"); + }; + const result = await serveInspection( + selected.provider, + selected.database.path, + ); + expect(result).toMatchObject({ + exitCode: 130, + output: { + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining(selected.path)], + diagnostics: { + primary_error: { + details: { + diagnostics: { previous_error: { code: "cancelled" } }, + }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }, + }); + expect(descriptorCleanup.attempts).toBeGreaterThanOrEqual(2); +}); + +it("retains reply-read cancellation and retries the failed reply descriptor close", async () => { + const selected = await fixture("reply"); + const controller = new AbortController(); + descriptorCleanup.onOpen = () => + controller.abort(new Error("requested cancellation during reply read")); + const result = await selected.provider.inspect( + { path: selected.database.path }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancelled reply read"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + execution_failure: "cancelled", + resources: [expect.stringContaining(descriptorCleanup.path)], + captured_output: { + stdout: "", + stderr: expect.any(String), + truncated: false, + }, + }, + }); + const handle = descriptorCleanup.handles[0]; + if (handle === undefined) throw new Error("Expected owned reply descriptor"); + expect(handle.fd).toBeGreaterThanOrEqual(0); + await expect(selected.provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + descriptorCleanup.denied = false; + await selected.provider.close(); + expect(handle.fd).toBe(-1); + for (const root of selected.roots) + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("retries source cleanup before admitting another snapshot", async () => { + const selected = await fixture("database"); + await selected.provider.inspect({ path: selected.database.path }); + const first = descriptorCleanup.handles[0]; + if (first === undefined) throw new Error("Expected source descriptor"); + descriptorCleanup.denied = false; + const resumed = await selected.provider.inspect({ + path: selected.database.path, + }); + expect(selected.roots).toHaveLength(2); + expect(first.fd).toBe(-1); + if (resumed.ok) throw new Error("The worker is deliberately unavailable"); + expect(resumed.error.cleanupIncomplete).toBe(false); + await selected.provider.close(); +}); + +it("keeps an invalidated descriptor close failure unconfirmed across retries", async () => { + const selected = await fixture("journal", true); + const first = await selected.provider.inspect({ + path: selected.database.path, + }); + expect(first).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(descriptorCleanup.handles[0]?.fd).toBe(-1); + descriptorCleanup.denied = false; + await expect(selected.provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + await expect(selected.provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(descriptorCleanup.attempts).toBe(1); +}); + +it.each(["EACCES", "EPERM"])( + "preserves %s read diagnostics alongside a failed descriptor close", + async (code) => { + const selected = await fixture("database"); + descriptorCleanup.readFailure = Object.assign( + new Error("injected source read denial"), + { code }, + ); + const result = await selected.provider.inspect({ + path: selected.database.path, + }); + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + diagnostics: { previous_error: { code: "access_denied" } }, + }, + }); + }, +); + +it.each(["database", "WAL", "journal"] as const)( + "preserves caller cancellation alongside a failed %s descriptor close", + async (member) => { + const selected = await fixture(member); + const controller = new AbortController(); + descriptorCleanup.onOpen = () => + controller.abort(new Error("requested caller cancellation")); + const result = await selected.provider.inspect( + { path: selected.database.path }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + diagnostics: { previous_error: { code: "cancelled" } }, + }, + }); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts new file mode 100644 index 0000000000..ba94792a57 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -0,0 +1,328 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFile, writeFile } from "node:fs/promises"; +import { expect, it as nativeTest, onTestFinished } from "vitest"; +import { SQLITE_NATIVE_LIMITS_AVAILABLE } from "../../fixtures/sqlite/database.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { SqliteInspectionFailure } from "../../../src/sqlite/SqliteDatabaseLimits.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const it = nativeTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE); + +const createDatabase = async (sql: string): Promise => { + const workspace = await createTestWorkspace("rea-sqlite-expansion-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("private.db"); + const producer = new DatabaseSync(path); + try { + producer.exec(sql); + } finally { + producer.close(); + } + return path; +}; + +it("preserves negative-zero REAL values through JSON separately from positive and integer zero", async () => { + const path = await createDatabase(` + CREATE TABLE numbers(value); + INSERT INTO numbers VALUES (-0.0), (0.0), (0), (5e-324), (-5e-324); + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "numbers", + }); + expect(JSON.parse(JSON.stringify(result.rows))).toMatchObject({ + values: [ + [{ type: "real", value: "-0" }], + [{ type: "real", value: 0 }], + [{ type: "integer", value: "0" }], + [{ type: "real", value: Number.MIN_VALUE }], + [{ type: "real", value: -Number.MIN_VALUE }], + ], + }); +}); + +it.each(["table", "view"] as const)( + "inspects columns when a %s shadows the table-valued PRAGMA name", + async (kind) => { + const path = await createDatabase(` + CREATE TABLE "selected "" table" ("0" TEXT, "" INTEGER); + INSERT INTO "selected "" table" VALUES ('retained', 7); + ${kind === "table" ? "CREATE TABLE pragma_table_xinfo(value TEXT)" : "CREATE VIEW pragma_table_xinfo AS SELECT 1"}; + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: 'selected " table', + }); + expect(result.schema.tables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'selected " table', + columns: [ + expect.objectContaining({ name: "0", declared_type: "TEXT" }), + expect.objectContaining({ name: "", declared_type: "INTEGER" }), + ], + }), + ]), + ); + expect(result.rows).toMatchObject({ + columns: ["0", ""], + values: [ + [ + { type: "text", value: "retained" }, + { type: "integer", value: "7" }, + ], + ], + }); + }, +); + +it.each([6, 9])( + "preserves a %i MiB schema statement without expanding its native record", + async (mebibytes) => { + const trigger = `CREATE TRIGGER large_definition AFTER INSERT ON records BEGIN /*${"x".repeat(mebibytes * 1024 * 1024)}*/ SELECT 1; END`; + const path = await createDatabase(` + CREATE TABLE records(value TEXT); + ${trigger}; + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + schema_scope: "database", + }); + expect(result.schema.triggers).toEqual([ + { name: "large_definition", table: "records", sql: trigger }, + ]); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + 16 * 1024 * 1024, + ); + }, +); + +it("inspects a large schema identifier without expanding a native sort record", async () => { + const name = "x".repeat(4 * 1024 * 1024); + const sql = `CREATE TABLE "${name}" (value TEXT)`; + const path = await createDatabase(sql); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + schema_scope: "database", + }); + expect(result.schema.tables).toMatchObject([ + { name, sql, columns: [{ name: "value", declared_type: "TEXT" }] }, + ]); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + 16 * 1024 * 1024, + ); +}); + +it("accounts for JSON escape expansion of a stored declaration in the reply limit", async () => { + const path = await createDatabase(` + CREATE TABLE records(value TEXT); + CREATE TRIGGER expanded AFTER INSERT ON records BEGIN /*${"\u0001".repeat(3 * 1024 * 1024)}*/ SELECT 1; END; + `); + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, schema_scope: "database" }), + ).toThrowError(expect.objectContaining({ reason: "output-limit" })); + const inventory = inspectSqliteDatabaseSnapshot(path, { path }); + expect(inventory.schema.triggers).toEqual([ + { name: "expanded", table: "records" }, + ]); +}); + +it.each(["UTF-8", "UTF-16le", "UTF-16be"])( + "preserves exact %s schema text when decoding native bytes", + async (encoding) => { + const sql = 'CREATE TABLE "\uFEFFé𝄞" ("列" TEXT DEFAULT \'\uFEFFvalue\')'; + const path = await createDatabase(`PRAGMA encoding='${encoding}'; ${sql}`); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + schema_scope: "database", + }); + expect(result.schema.tables).toMatchObject([ + { + name: "\uFEFFé𝄞", + sql, + columns: [{ name: "列", default_sql: "'\uFEFFvalue'" }], + }, + ]); + }, +); + +it("counts schema names once when enforcing the complete reply budget", async () => { + const names = Array.from( + { length: 48 }, + (_, index) => `${index}_${"x".repeat(128 * 1024)}`, + ); + const path = await createDatabase( + names.map((name) => `CREATE TABLE "${name}" (value TEXT);`).join("\n"), + ); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + schema_scope: "database", + }); + expect(result.schema.tables).toHaveLength(names.length); + expect(result.schema.tables.map((table) => table.name)).toEqual( + expect.arrayContaining(names), + ); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan( + 16 * 1024 * 1024, + ); +}); + +it("rejects malformed schema bytes instead of returning replacement text", async () => { + const path = await createDatabase( + "CREATE TABLE records(value TEXT /*schema-marker*/)", + ); + const bytes = await readFile(path); + const marker = bytes.indexOf(Buffer.from("schema-marker")); + if (marker < 0) throw new Error("Schema marker missing"); + bytes[marker] = 0xff; + await writeFile(path, bytes); + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, schema_scope: "database" }), + ).toThrow(/schema sql contains malformed UTF-8 text/); +}); + +it("refuses possible shadow tables when their virtual module is unavailable", async () => { + const path = await createDatabase(` + CREATE VIRTUAL TABLE "SeArCh_É" USING fts5(body); + INSERT INTO "SeArCh_É" VALUES ('retained'); + CREATE TABLE "search_É_manual" (value TEXT); + CREATE TABLE "search_é_manual" (value TEXT); + INSERT INTO "search_é_manual" VALUES ('distinct Unicode name'); + CREATE TABLE "SeArCh_Élite" (value TEXT); + `); + const bytes = await readFile(path); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) throw new Error("Virtual declaration missing"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(path, bytes); + + for (const table of ["SeArCh_É_data", "search_É_manual"]) + expect(() => inspectSqliteDatabaseSnapshot(path, { path, table })).toThrow( + /cannot determine.*shadow table/i, + ); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "search_é_manual", + schema_scope: "database", + }); + expect(result.schema.tables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "SeArCh_É", kind: "virtual" }), + expect.objectContaining({ name: "SeArCh_É_data", kind: "unknown" }), + expect.objectContaining({ name: "search_É_manual", kind: "unknown" }), + expect.objectContaining({ name: "search_é_manual", kind: "table" }), + expect.objectContaining({ name: "SeArCh_Élite", kind: "table" }), + ]), + ); + expect(result.rows).toMatchObject({ + values: [[{ type: "text", value: "distinct Unicode name" }]], + }); + expect(result.limitations.join("\n")).toMatch(/shadow table/i); +}); + +it("rejects an oversized generated cell through SQLite's native value limit", async () => { + const path = await createDatabase(` + CREATE TABLE records ( + id INTEGER, + payload BLOB GENERATED ALWAYS AS (zeroblob(72 * 1024 * 1024)) VIRTUAL + ); + INSERT INTO records(id) VALUES (1); + `); + try { + inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + throw new Error("An oversized generated cell must not be projected"); + } catch (cause: unknown) { + expect(cause).toMatchObject({ + code: "ERR_SQLITE_ERROR", + errcode: 18, + }); + } +}); + +it("reports additional rows without expanding an unselected generated cell", async () => { + const path = await createDatabase(` + CREATE TABLE records ( + id INTEGER, + payload BLOB GENERATED ALWAYS AS ( + CASE WHEN id = 2 THEN zeroblob(72 * 1024 * 1024) ELSE X'01' END + ) VIRTUAL + ); + INSERT INTO records(id) VALUES (1), (2); + `); + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + expect(result.rows).toMatchObject({ + columns: ["id", "payload"], + values: [ + [ + { type: "integer", value: "1" }, + { type: "blob", hex: "01" }, + ], + ], + returned_rows: 1, + truncated: true, + }); +}); + +it("rejects the combined representation of individually small generated cells", async () => { + const columns = Array.from( + { length: 6 }, + (_, index) => + `payload_${String(index)} BLOB GENERATED ALWAYS AS (zeroblob(2 * 1024 * 1024)) VIRTUAL`, + ); + const path = await createDatabase(` + CREATE TABLE records (id INTEGER, ${columns.join(", ")}); + INSERT INTO records(id) VALUES (1); + `); + try { + inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + row_limit: 1, + }); + throw new Error( + "Aggregate row expansion must be rejected before projection", + ); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(SqliteInspectionFailure); + expect(cause).toMatchObject({ reason: "output-limit" }); + } +}); + +it("samples above 1000 rows and safe integer limits within the byte budget", async () => { + const path = await createDatabase(`CREATE TABLE items(value INTEGER); + WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers WHERE value < 1002) + INSERT INTO items SELECT value FROM numbers;`); + for (const row_limit of [1001, Number.MAX_SAFE_INTEGER]) { + const result = inspectSqliteDatabaseSnapshot(path, { + path, + table: "items", + row_limit, + }); + expect(result.rows).toMatchObject({ + row_limit, + returned_rows: Math.min(row_limit, 1002), + truncated: row_limit < 1002, + }); + } +}); + +nativeTest.skipIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( + "rejects a runtime without native limits before querying the snapshot", + async () => { + const path = await createDatabase("CREATE TABLE items(value INTEGER)"); + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, schema_scope: "database" }), + ).toThrow("DatabaseSync.limits"); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts new file mode 100644 index 0000000000..24dadc1287 --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -0,0 +1,595 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { access, lstat, readFile, symlink, writeFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { expect, it as nativeTest, onTestFinished } from "vitest"; +import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; +import { + createSqliteDatabaseFixture, + SQLITE_NATIVE_LIMITS_AVAILABLE, +} from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const it = nativeTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE); + +const workerUrl = new URL( + "../../../dist/sqlite/SqliteDatabaseWorker.js", + import.meta.url, +); +const createProvider = () => + new SqliteDatabaseProvider(process.env, (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 ? fileURLToPath(workerUrl) : argument, + ), + }), + ); + +const createFixture = async (wal = false) => { + const workspace = await createTestWorkspace("rea-sqlite-boundary-"); + const database = createSqliteDatabaseFixture(workspace.root, wal); + if (!wal) database.close(); + onTestFinished(async () => { + if (wal) database.close(); + await removeTestWorkspace(workspace.root); + }); + return { workspace, database }; +}; + +const expectPrivateRootRemoved = (path: string) => + expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); + +it.each(["late-cancellation", "cleanup-failure"])( + "preserves completed native-worker observations across %s", + async (scenario) => { + const { workspace, database } = await createFixture(); + const original = await readFile(database.path); + const controller = new AbortController(); + let cleanupAllowed = false; + let root: PrivateRuntimeRoot | undefined; + const provider = new SqliteDatabaseProvider( + process.env, + (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + arguments: spawn.arguments.map((argument, index) => + index === 1 ? fileURLToPath(workerUrl) : argument, + ), + }), + async () => { + root = await PrivateRuntimeRoot.create({ parent: workspace.root }); + const acquired = root; + return { + path: acquired.path, + close: async () => { + if (scenario === "late-cancellation") controller.abort(); + else if (!cleanupAllowed) + throw new Error("Completed snapshot cleanup denied"); + await acquired.close(); + }, + }; + }, + ); + onTestFinished(async () => { + cleanupAllowed = true; + await provider.close(); + }); + const result = await provider.inspect( + { path: database.path, table: "records", columns: ["id"], row_limit: 1 }, + { signal: controller.signal }, + ); + if (scenario === "late-cancellation") { + expect(controller.signal.aborted).toBe(true); + expect(result).toMatchObject({ + ok: true, + value: { result: { rows: { columns: ["id"], returned_rows: 1 } } }, + }); + } else { + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + partialObservation: { + operation: "inspect_sqlite_database", + confidence: "observed", + subject: { + local_path: database.path, + digest: { sha256: digestForTest(original) }, + }, + parameters: { + path: database.path, + table: "records", + columns: ["id"], + row_limit: 1, + }, + normalized_result: { rows: { columns: ["id"], returned_rows: 1 } }, + }, + }, + }); + if (root === undefined) throw new Error("Missing retained root"); + await expect(access(root.path)).resolves.toBeUndefined(); + cleanupAllowed = true; + await provider.close(); + await expectPrivateRootRemoved(root.path); + } + expect(await readFile(database.path)).toEqual(original); + }, +); + +const digestForTest = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +it("inspects committed WAL schema and rows while preserving every source artifact", async () => { + const { database } = await createFixture(true); + const paths = [database.path, database.walPath, `${database.path}-shm`]; + const original = await Promise.all( + paths.map(async (path) => ({ + path, + bytes: await readFile(path), + stat: await lstat(path), + })), + ); + const result = await createProvider().inspect({ + path: database.path, + table: "records", + schema_scope: "database", + row_limit: 1, + }); + if (!result.ok) throw result.error; + const main = original[0]; + const wal = original[1]; + if (main === undefined || wal === undefined) + throw new Error("Missing fixture identity"); + expect(result.value.result).toMatchObject({ + artifact: { + path: database.path, + bytes: main.bytes.length, + sha256: createHash("sha256").update(main.bytes).digest("hex"), + }, + wal: { + path: database.walPath, + bytes: wal.bytes.length, + sha256: createHash("sha256").update(wal.bytes).digest("hex"), + }, + schema: { + completeness: "complete", + tables: expect.arrayContaining([ + { + name: "records", + sql: expect.stringContaining("CREATE TABLE"), + root_page: expect.any(Number), + kind: "table", + without_rowid: false, + strict: false, + column_count: 7, + columns_completeness: "complete", + columns: expect.arrayContaining([ + expect.objectContaining({ name: "generated_value", hidden: 2 }), + ]), + }, + ]), + indexes: [ + expect.objectContaining({ name: "records_text", table: "records" }), + ], + views: [ + { + name: "record_names", + sql: expect.stringContaining("SELECT text_value"), + }, + ], + triggers: [ + { + name: "records_insert", + table: "records", + sql: expect.stringContaining("AFTER INSERT"), + }, + ], + }, + rows: { + table: "records", + returned_rows: 1, + truncated: true, + row_limit: 1, + order: "unspecified", + }, + }); + for (const artifact of original) { + expect(await readFile(artifact.path)).toEqual(artifact.bytes); + const after = await lstat(artifact.path); + expect({ + ino: after.ino, + size: after.size, + mtimeMs: after.mtimeMs, + ctimeMs: after.ctimeMs, + }).toEqual({ + ino: artifact.stat.ino, + size: artifact.stat.size, + mtimeMs: artifact.stat.mtimeMs, + ctimeMs: artifact.stat.ctimeMs, + }); + } +}); + +it("preserves SQLite integer, binary, nonfinite, null, and text byte values", async () => { + const { database } = await createFixture(); + const result = await createProvider().inspect({ + path: database.path, + table: "records", + row_limit: 10, + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + wal: null, + rows: { + columns: [ + "id", + "integer_value", + "blob_value", + "real_value", + "nullable_value", + "text_value", + "generated_value", + ], + returned_rows: 3, + truncated: false, + values: [ + [ + { type: "integer", value: "1" }, + { type: "integer", value: "9223372036854775807" }, + { type: "blob", hex: "001fefff" }, + { type: "real", value: "Infinity" }, + { type: "null" }, + { + type: "text", + value: "first é\0value", + bytes_hex: Buffer.from("first é\0value").toString("hex"), + }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + [ + { type: "integer", value: "2" }, + { type: "integer", value: "-9223372036854775808" }, + { type: "blob", hex: "" }, + { type: "real", value: "-Infinity" }, + { + type: "text", + value: "present", + bytes_hex: Buffer.from("present").toString("hex"), + }, + { + type: "text", + value: "second", + bytes_hex: Buffer.from("second").toString("hex"), + }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + [ + { type: "integer", value: "3" }, + { type: "integer", value: "0" }, + { type: "blob", hex: "ff" }, + { type: "real", value: 1.25 }, + { type: "null" }, + { type: "text", bytes_hex: "ff00c0af" }, + { + type: "text", + value: "integer", + bytes_hex: Buffer.from("integer").toString("hex"), + }, + ], + ], + }, + }); + expect(() => JSON.stringify(result.value.result)).not.toThrow(); +}); + +it("selects an exact quoted table and refuses to execute table input as SQL", async () => { + const { database } = await createFixture(); + const provider = createProvider(); + const selected = await provider.inspect({ + path: database.path, + table: database.quotedTable, + row_limit: 1, + }); + if (!selected.ok) throw selected.error; + expect(selected.value.result).toMatchObject({ + rows: { + table: database.quotedTable, + columns: ["__proto__"], + values: [ + [ + { + type: "text", + value: "exact selected table", + bytes_hex: Buffer.from("exact selected table").toString("hex"), + }, + ], + ], + returned_rows: 1, + truncated: false, + }, + }); + const unknown = await provider.inspect({ + path: database.path, + table: 'records"; DROP TABLE records;--', + row_limit: 1, + }); + expect(unknown).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const after = await provider.inspect({ + path: database.path, + table: "records", + row_limit: 10, + }); + if (!after.ok) throw after.error; + expect(after.value.result).toMatchObject({ rows: { returned_rows: 3 } }); +}); + +it("does not execute views when selecting rows", async () => { + const { database } = await createFixture(); + const result = await createProvider().inspect({ + path: database.path, + table: "record_names", + row_limit: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("rejects malformed databases without altering the selected file", async () => { + const workspace = await createTestWorkspace("rea-sqlite-invalid-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = await workspace.write( + "not-a-database.db", + "selected non-SQLite bytes", + ); + const bytes = await readFile(path); + const result = await createProvider().inspect({ path }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(await readFile(path)).toEqual(bytes); +}); + +nativeTest.runIf(process.platform !== "win32")( + "rejects a symlink database", + async () => { + const { database, workspace } = await createFixture(); + const alias = workspace.path("alias.db"); + await symlink(database.path, alias); + const provider = createProvider(); + expect(await provider.inspect({ path: alias })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +nativeTest( + "rejects an actual open rollback transaction without changing its journal", + async () => { + const { database } = await createFixture(); + const writer = new DatabaseSync(database.path); + writer.exec( + "BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted')", + ); + try { + const main = await readFile(database.path); + const journalPath = `${database.path}-journal`; + const journal = await readFile(journalPath); + expect(journal.length).toBeGreaterThan(0); + expect( + await createProvider().inspect({ path: database.path }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(await readFile(database.path)).toEqual(main); + expect(await readFile(journalPath)).toEqual(journal); + } finally { + writer.exec("ROLLBACK"); + writer.close(); + } + }, +); + +nativeTest.runIf(process.platform !== "win32")( + "rejects FIFO input without waiting for a writer", + async () => { + const workspace = await createTestWorkspace("rea-sqlite-fifo-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("database.fifo"); + await promisify(execFile)("mkfifo", [path]); + const result = await createProvider().inspect({ path }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }, +); + +nativeTest( + "honors a cancelled caller before opening the database", + async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + controller.abort(); + expect( + await createProvider().inspect( + { path: database.path }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + }, +); + +nativeTest( + "removes an acquired private root when cancelled before snapshot creation", + async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + let ownedRoot = ""; + const provider = new SqliteDatabaseProvider( + {}, + () => { + throw new Error("Cancelled request must not launch SQLite"); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-sqlite-cancel-root-", + }); + ownedRoot = root.path; + controller.abort(); + return root; + }, + ); + expect( + await provider.inspect( + { path: database.path }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + await expectPrivateRootRemoved(ownedRoot); + }, +); + +it.each(["UTF-8", "UTF-16le", "UTF-16be"] as const)( + "preserves %s text, a leading BOM and empty table selection", + async (encoding) => { + const workspace = await createTestWorkspace("rea-sqlite-encoding-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("encoding.db"); + const writer = new DatabaseSync(path); + writer.exec( + `PRAGMA encoding='${encoding}'; CREATE TABLE "" ("1" TEXT, "0" TEXT)`, + ); + const value = "\uFEFFé𝄞\0selected"; + writer + .prepare('INSERT INTO "" VALUES (?, ?)') + .run(value, "second numeric column"); + writer.close(); + const bytes = Buffer.from(value, encoding === "UTF-8" ? "utf8" : "utf16le"); + if (encoding === "UTF-16be") bytes.swap16(); + const result = await createProvider().inspect({ + path, + table: "", + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + database_encoding: encoding, + rows: { + table: "", + columns: ["1", "0"], + returned_rows: 1, + values: [ + [ + { type: "text", value, bytes_hex: bytes.toString("hex"), encoding }, + { type: "text", value: "second numeric column" }, + ], + ], + }, + }); + }, +); + +it("retains an unavailable virtual module definition without executing it", async () => { + const workspace = await createTestWorkspace("rea-sqlite-virtual-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path("virtual.db"); + const writer = new DatabaseSync(path); + writer.exec( + "CREATE VIRTUAL TABLE optional_search USING fts5(body); INSERT INTO optional_search VALUES ('selected source')", + ); + writer.close(); + const bytes = await readFile(path); + const declaration = bytes.indexOf(Buffer.from("USING fts5")); + if (declaration < 0) + throw new Error("SQLite virtual module declaration not found"); + bytes.write("zzzz", declaration + Buffer.byteLength("USING "), "utf8"); + await writeFile(path, bytes); + const provider = createProvider(); + const result = await provider.inspect({ path, schema_scope: "database" }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + rows: null, + schema: { + tables: expect.arrayContaining([ + expect.objectContaining({ + name: "optional_search", + kind: "virtual", + sql: expect.stringContaining("USING zzzz"), + columns: [], + columns_completeness: "unsupported", + }), + ]), + }, + }); + expect( + await provider.inspect({ path, table: "optional_search" }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + expect(await readFile(path)).toEqual(bytes); +}); + +nativeTest( + "cancels an acquired SQLite worker and removes its private snapshot", + async () => { + const { database } = await createFixture(); + const controller = new AbortController(); + let ownedRoot = ""; + let pid: number | undefined; + const provider = new SqliteDatabaseProvider({}, async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + const requestPath = spawn.arguments[2]; + if (requestPath === undefined) + throw new Error("SQLite worker arguments missing"); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: [ + "--input-type=module", + "-e", + `await import(${JSON.stringify(workerUrl.href)}); process.stdout.write('ready\\n'); setInterval(() => {}, 1000);`, + "sqlite-worker-test", + requestPath, + ], + }); + pid = launched.process.pid; + await waitForProviderProcessReady(launched.process); + await access(`${ownedRoot}/reply.json`); + setImmediate(() => controller.abort()); + return launched; + }); + const result = await provider.inspect( + { path: database.path, table: "records" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (pid === undefined) throw new Error("SQLite worker not acquired"); + const acquiredPid = pid; + expect(() => process.kill(acquiredPid, 0)).toThrow(); + await expectPrivateRootRemoved(ownedRoot); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts new file mode 100644 index 0000000000..4fce636ceb --- /dev/null +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -0,0 +1,264 @@ +import { + lstat, + open, + readFile, + rename, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js"; +import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { sqliteDatabaseFailure } from "../../../src/sqlite/SqliteDatabaseFailures.js"; +import { + createSqliteDatabaseFixture, + SQLITE_NATIVE_LIMITS_AVAILABLE, +} from "../../fixtures/sqlite/database.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const fixture = async (wal = false) => { + const workspace = await createTestWorkspace("rea-sqlite-snapshot-race-"); + const database = createSqliteDatabaseFixture(workspace.root, wal); + if (!wal) database.close(); + const resourceScope = new ArtifactResourceScope(); + onTestFinished(async () => { + await resourceScope.close(); + if (wal) database.close(); + await removeTestWorkspace(workspace.root); + }); + const root = await workspace.mkdir("private"); + const capture = (fileSystem?: StableArtifactFileSystem) => + captureSqliteDatabaseSnapshot( + database.path, + root, + resourceScope, + undefined, + fileSystem, + ); + return { workspace, database, root, capture }; +}; + +it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE).each(["PERSIST", "TRUNCATE"])( + "inspects a committed %s journal without modifying the source file set", + async (mode) => { + const { database, capture } = await fixture(); + const writer = new DatabaseSync(database.path); + try { + writer.exec( + `PRAGMA journal_mode=${mode}; INSERT INTO records(id, text_value) VALUES (4, 'committed');`, + ); + } finally { + writer.close(); + } + const journalPath = `${database.path}-journal`; + const before = await readFile(journalPath); + const beforeStat = await lstat(journalPath, { bigint: true }); + const snapshot = await capture(); + const result = inspectSqliteDatabaseSnapshot(snapshot.snapshotPath, { + path: database.path, + table: "records", + }); + expect(result.rows).toMatchObject({ returned_rows: 4, truncated: false }); + expect(await readFile(journalPath)).toEqual(before); + const afterStat = await lstat(journalPath, { bigint: true }); + expect([ + afterStat.ino, + afterStat.size, + afterStat.mtimeNs, + afterStat.ctimeNs, + ]).toEqual([ + beforeStat.ino, + beforeStat.size, + beforeStat.mtimeNs, + beforeStat.ctimeNs, + ]); + }, +); + +it.runIf(process.platform !== "win32").each([ + ["database", "remove"], + ["WAL", "remove"], + ["database", "replace with symlink"], + ["WAL", "replace with symlink"], +])( + "reports an observed %s change during acquisition as changed evidence: %s", + async (member, change) => { + const { database, workspace, capture } = await fixture(true); + const replacement = await workspace.write("replacement", ""); + const selectedPath = + member === "database" ? database.path : database.walPath; + let observations = 0; + const fileSystem: StableArtifactFileSystem = { + lstat: async (path) => { + if (path === selectedPath && ++observations === 2) { + await unlink(path); + if (change === "replace with symlink") + await symlink(replacement, path); + } + return lstat(path, { bigint: true }); + }, + open, + }; + try { + await capture(fileSystem); + throw new Error("A disappearing source must be rejected"); + } catch (cause) { + expect( + sqliteDatabaseFailure(cause, database.path, "artifact-read"), + ).toMatchObject({ _tag: "AnalysisArtifactChangedError" }); + expect(cause).toMatchObject({ + message: expect.stringContaining(selectedPath), + }); + } + }, +); + +it("refuses an active journal even before SQLite writes its magic header", async () => { + const { database, capture } = await fixture(); + const writer = new DatabaseSync(database.path); + try { + writer.exec( + "PRAGMA journal_mode=PERSIST; BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted');", + ); + const journalPath = `${database.path}-journal`; + const journal = await readFile(journalPath); + expect(journal.subarray(0, 8).every((byte) => byte === 0)).toBe(true); + expect(journal.subarray(8, 28).some((byte) => byte !== 0)).toBe(true); + const original = await readFile(database.path); + await expect(capture()).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining("recovery"), + }); + expect(await readFile(database.path)).toEqual(original); + expect(await readFile(journalPath)).toEqual(journal); + } finally { + writer.exec("ROLLBACK"); + writer.close(); + } +}); + +it("refuses an incomplete zeroed journal header", async () => { + const { database, capture } = await fixture(); + await writeFile(`${database.path}-journal`, Buffer.alloc(27)); + await expect(capture()).rejects.toMatchObject({ reason: "format" }); +}); + +it.runIf(process.platform !== "win32")( + "refuses a journal symlink even when its target is empty", + async () => { + const { database, workspace, capture } = await fixture(); + const empty = await workspace.write("empty-journal", ""); + await symlink(empty, `${database.path}-journal`); + await expect(capture()).rejects.toMatchObject({ reason: "path" }); + }, +); + +it.each(["activate", "replace", "remove"])( + "rejects a cold journal changed during database acquisition: %s", + async (change) => { + const { database, capture } = await fixture(); + const journalPath = `${database.path}-journal`; + await writeFile(journalPath, Buffer.alloc(28)); + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.path) { + if (change === "activate") + await writeFile(journalPath, Buffer.alloc(28, 1)); + else if (change === "remove") await unlink(journalPath); + else { + await rename(journalPath, `${journalPath}.previous`); + await writeFile(journalPath, Buffer.alloc(28)); + } + } + return open(path, flags); + }, + }; + await expect(capture(fileSystem)).rejects.toMatchObject({ + reason: "integrity", + }); + }, +); + +it("refuses an activated journal even when filesystem metadata cannot distinguish the write", async () => { + const { database, capture } = await fixture(); + const journalPath = `${database.path}-journal`; + await writeFile(journalPath, Buffer.alloc(28)); + const observed = await lstat(journalPath, { bigint: true }); + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => + path === journalPath + ? Promise.resolve(observed) + : lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.path) + await writeFile(journalPath, Buffer.alloc(28, 1)); + const handle = await open(path, flags); + if (path === journalPath) + vi.spyOn(handle, "stat").mockResolvedValue(observed); + return handle; + }, + }; + await expect(capture(fileSystem)).rejects.toMatchObject({ + reason: "integrity", + }); +}); + +it("rejects a database changed after its copy but before WAL acquisition", async () => { + const { database, root, capture } = await fixture(true); + const initial = await readFile(database.path); + const initialStat = await lstat(database.path); + let changed = false; + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.walPath) { + const bytes = Buffer.from(initial); + const offset = bytes.length - 1; + bytes[offset] = (bytes[offset] ?? 0) ^ 1; + await writeFile(database.path, bytes); + await utimes(database.path, initialStat.atime, initialStat.mtime); + changed = true; + } + return open(path, flags); + }, + }; + await expect(capture(fileSystem)).rejects.toMatchObject({ + reason: "integrity", + }); + expect(changed).toBe(true); + expect(await readFile(`${root}/database.snapshot`)).toEqual(initial); +}); + +it.each(["-wal", "-journal"] as const)( + "rejects a previously absent %s created during database acquisition", + async (suffix) => { + const { database, capture } = await fixture(); + let created = false; + const fileSystem: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open: async (path, flags) => { + if (path === database.path) { + await writeFile( + `${database.path}${suffix}`, + "newly selected sidecar", + ); + created = true; + } + return open(path, flags); + }, + }; + await expect(capture(fileSystem)).rejects.toMatchObject({ + reason: "integrity", + }); + expect(created).toBe(true); + }, +); diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 212cf4f5b3..7319a5501a 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -159,3 +159,6 @@ for (const name of ["get_xrefs_to_class", "get_xrefs_to_method"]) }), ); await server.connect(new StdioServerTransport()); +// A refused cleanup must leave a live process for verified fixture teardown. +// Otherwise stdin EOF races the ownership scan with the server's natural exit. +if (mode === "cleanup-failure") setInterval(() => {}, 1000); diff --git a/tests/fixtures/sqlite/database.ts b/tests/fixtures/sqlite/database.ts new file mode 100644 index 0000000000..ae174c44d7 --- /dev/null +++ b/tests/fixtures/sqlite/database.ts @@ -0,0 +1,74 @@ +import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; + +/** Engine-dependent cases require the same native limit capability as the worker. */ +export const SQLITE_NATIVE_LIMITS_AVAILABLE = (() => { + const database = new DatabaseSync(":memory:"); + try { + return "limits" in database; + } finally { + database.close(); + } +})(); + +/** Exact selected SQLite fixture with committed rows and optional live WAL. */ +export interface SqliteDatabaseFixture { + readonly path: string; + readonly walPath: string; + readonly quotedTable: string; + close(): void; +} + +/** Produce schema and lossless SQLite values through the actual SQLite engine. */ +export const createSqliteDatabaseFixture = ( + root: string, + wal = false, +): SqliteDatabaseFixture => { + const path = join(root, "selected.db"); + const db = new DatabaseSync(path); + if (wal) db.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0"); + db.exec(` + CREATE TABLE records ( + id INTEGER PRIMARY KEY, + integer_value INTEGER, + blob_value BLOB, + real_value REAL, + nullable_value TEXT, + text_value TEXT, + generated_value TEXT GENERATED ALWAYS AS (typeof(integer_value)) VIRTUAL + ); + CREATE INDEX records_text ON records(text_value); + CREATE VIEW record_names AS SELECT text_value FROM records; + CREATE TRIGGER records_insert AFTER INSERT ON records BEGIN SELECT 1; END; + CREATE TABLE "strange""'; DROP TABLE records;--" ("__proto__" TEXT); + INSERT INTO "strange""'; DROP TABLE records;--" VALUES ('exact selected table'); + `); + const insert = db.prepare( + "INSERT INTO records(id, integer_value, blob_value, real_value, nullable_value, text_value) VALUES (?, ?, ?, ?, ?, ?)", + ); + insert.run( + 1, + 9223372036854775807n, + Buffer.from([0, 31, 239, 255]), + Infinity, + null, + "first é\0value", + ); + insert.run( + 2, + -9223372036854775808n, + Buffer.alloc(0), + -Infinity, + "present", + "second", + ); + db.exec( + "INSERT INTO records(id, integer_value, blob_value, real_value, nullable_value, text_value) VALUES (3, 0, X'FF', 1.25, NULL, CAST(X'FF00C0AF' AS TEXT))", + ); + return { + path, + walPath: `${path}-wal`, + quotedTable: "strange\"'; DROP TABLE records;--", + close: () => db.close(), + }; +}; From 0043d9b134c0a353e8bf85c0bde4492275f1f720 Mon Sep 17 00:00:00 2001 From: LouisChen <65096591@qq.com> Date: Tue, 13 Oct 2026 02:36:45 +0800 Subject: [PATCH 31/93] fix(browser): report mismatched event family selection as unknown in capture comparison (#2239) * fix(browser): report mismatched event family selection as unknown in capture comparison * fix(browser): retain selected event families and unknown coverage Fold the complementary implementation from #2253 into the earlier #2239 proposal, retaining the original contribution history. Co-authored-by: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> --------- Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> --- docs/browser-scenario-contract.md | 6 ++ .../BrowserScenarioPartialObservation.ts | 1 + .../PlaywrightBrowserScenarioProvider.test.ts | 53 +++++++++++ src/contracts/browserToolContracts.ts | 1 + src/domain/browserScenarioCapture.ts | 2 + src/domain/browserScenarioCaptureValues.ts | 15 ++++ src/domain/browserScenarioDiff.test.ts | 90 +++++++++++++++++++ src/domain/browserScenarioDiff.ts | 14 ++- src/domain/browserScenarioDiffEvidence.ts | 20 +++++ src/domain/browserScenarioValues.ts | 16 +--- tests/boundary/cli/browserCli.test.ts | 1 + tests/boundary/mcp/browserScenarioMcp.test.ts | 1 + tests/fixtures/webScriptCapture.ts | 1 + 13 files changed, 206 insertions(+), 15 deletions(-) diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index b994d29368..403865be8c 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -312,3 +312,9 @@ artifacts carry their normalized before/after digests and capture states. Every changed or unknown artifact record is returned inline for each aligned step. Missing, truncated, or mismatched capture coverage remains unknown instead of being treated as equality. + +`scenario.event_families` records the sorted, de-duplicated `capture.events` +selection. Captures with different selections report event evidence as unknown +with a recovery instruction, even when no selected family emitted an event. +Captures written before this field existed omit it, so their event evidence is +also unknown. diff --git a/src/browser/BrowserScenarioPartialObservation.ts b/src/browser/BrowserScenarioPartialObservation.ts index 1fd395a649..ca0df42739 100644 --- a/src/browser/BrowserScenarioPartialObservation.ts +++ b/src/browser/BrowserScenarioPartialObservation.ts @@ -89,6 +89,7 @@ export const browserScenarioCaptureData = (input: { .map(({ secret_id: id }) => id) .sort(), network_content: input.scenario.capture.network, + event_families: [...new Set(input.scenario.capture.events)].sort(), }, duration_ms: Date.now() - input.startedAt, steps: [...input.steps], diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index 6d4e284db8..aacf766ad9 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -14,10 +14,13 @@ import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js"; import { browserStepArtifactsSchema, type BrowserScenarioEvent, + type BrowserScenarioEventFamily, type BrowserStepArtifacts, } from "../domain/browserScenarioCaptureValues.js"; import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { compareBrowserScenarios } from "../domain/browserScenarioDiff.js"; +import { compareBrowserScenariosInputSchema } from "../domain/browserScenarioDiffValues.js"; import { AnalysisCancelledError, AnalysisOutputError, @@ -373,6 +376,56 @@ describe("PlaywrightBrowserScenarioProvider", () => { }); }); +describe("PlaywrightBrowserScenarioProvider comparison coverage", () => { + it("keeps quiet captures with different event families from comparing unchanged", async () => { + const capture = async (events: readonly BrowserScenarioEventFamily[]) => { + const provider = new PlaywrightBrowserScenarioProvider(() => + Promise.resolve(new FakeSession("launch")), + ); + const result = await provider.captureScenario(scenario({ events })); + if (!result.ok) throw result.error; + return result.value; + }; + const compare = async ( + before: readonly BrowserScenarioEventFamily[], + after: readonly BrowserScenarioEventFamily[], + ) => + compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: await capture(before), + after_scenario: await capture(after), + }), + ); + + const recorded = await capture(["page-errors", "console", "console"]); + expect(recorded.completeness.equality_eligible).toBe(true); + expect(recorded.scenario.event_families).toEqual([ + "console", + "page-errors", + ]); + for (const [before, after] of [ + [[], ["console"]], + [["console"], ["page-errors"]], + ] as const) { + const compared = await compare(before, after); + expect(compared.overall_status).toBe("unknown"); + expect( + compared.steps.flatMap((step) => step.artifact_diffs), + ).toContainEqual( + expect.objectContaining({ + artifact: "events", + status: "unknown", + reason: expect.stringContaining("Event family selections differ"), + }), + ); + } + expect( + (await compare(["console", "page-errors"], ["page-errors", "console"])) + .overall_status, + ).toBe("unchanged"); + }); +}); + describe("PlaywrightBrowserScenarioProvider cleanup", () => { it("returns a cleanup failure with completed observations when close fails", async () => { const session = new FakeSession("launch", { diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 7d5a233134..70111640af 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -84,6 +84,7 @@ const exampleScenarioCapture = () => ({ response_body: false, header_values: false, }, + event_families: [], }, duration_ms: 10, steps: [ diff --git a/src/domain/browserScenarioCapture.ts b/src/domain/browserScenarioCapture.ts index 8ad389588d..61c86b4310 100644 --- a/src/domain/browserScenarioCapture.ts +++ b/src/domain/browserScenarioCapture.ts @@ -3,6 +3,7 @@ import { browserNetworkContentSelectionValuesSchema } from "./browserNetworkEvid import { browserScenarioCompletenessSchema, + browserScenarioEventFamilySchema, browserScenarioEventSchema, browserScenarioStepSchema, } from "./browserScenarioCaptureValues.js"; @@ -22,6 +23,7 @@ export const browserScenarioCaptureSchema = z action_count: z.number().int().min(1), secret_references: z.array(z.string().min(1)), network_content: browserNetworkContentSelectionValuesSchema, + event_families: z.array(browserScenarioEventFamilySchema).optional(), }), duration_ms: z.number().int().min(0), steps: z.array(browserScenarioStepSchema).min(1), diff --git a/src/domain/browserScenarioCaptureValues.ts b/src/domain/browserScenarioCaptureValues.ts index 81fef0c15e..26c1559bb3 100644 --- a/src/domain/browserScenarioCaptureValues.ts +++ b/src/domain/browserScenarioCaptureValues.ts @@ -119,6 +119,21 @@ export const browserStepArtifactsSchema = z.strictObject({ }); export type BrowserStepArtifacts = z.infer; +/** Event families a scenario can select for capture. */ +export const browserScenarioEventFamilySchema = z.enum([ + "console", + "page-errors", + "network", + "websockets", + "frames", + "workers", + "popups", + "downloads", +]); +export type BrowserScenarioEventFamily = z.infer< + typeof browserScenarioEventFamilySchema +>; + const eventBase = { sequence: z.number().int().min(1), step_index: z.number().int().min(0), diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index 4c5272b9c8..f15bd9da0c 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -62,6 +62,7 @@ const networkCapture = (sourceSequence: number, receiptStep: number) => { response_body: false, header_values: false, }, + event_families: ["network"], }, events: { items: events, retained: 2, dropped: 0 }, }); @@ -146,6 +147,94 @@ it("reports content-selection mismatch as unknown instead of a website change", ).toBe(false); }); +it("reports event-family mismatch and legacy selection as unknown", () => { + const consoleOnly = scenarioCapture({}); + const withFamilies = ( + capture: BrowserScenarioCapture, + families: BrowserScenarioCapture["scenario"]["event_families"], + ) => + browserScenarioCaptureSchema.parse({ + ...capture, + scenario: { ...capture.scenario, event_families: families }, + }); + const compare = ( + before: BrowserScenarioCapture, + after: BrowserScenarioCapture, + ) => + compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + }), + ); + const eventDiffs = (compared: ReturnType) => + compared.steps + .flatMap((step) => step.artifact_diffs) + .filter(({ artifact }) => artifact === "events"); + + const widened = withFamilies(consoleOnly, ["page-errors", "console"]); + const mismatched = compare(consoleOnly, widened); + expect(mismatched.overall_status).toBe("unknown"); + expect(eventDiffs(mismatched)).toContainEqual( + expect.objectContaining({ + status: "unknown", + before_state: "captured", + after_state: "captured", + reason: expect.stringContaining( + "Event family selections differ (console before, console, page-errors after)", + ), + }), + ); + expect( + compare( + withFamilies(consoleOnly, ["console", "page-errors"]), + withFamilies(consoleOnly, ["page-errors", "console"]), + ).overall_status, + ).toBe("unchanged"); + + const legacy = withFamilies(consoleOnly, undefined); + expect(legacy.scenario.event_families).toBeUndefined(); + for (const [before, after] of [ + [legacy, consoleOnly], + [legacy, legacy], + ] as const) + expect(eventDiffs(compare(before, after))).toContainEqual( + expect.objectContaining({ + status: "unknown", + reason: expect.stringContaining("Event family selection is unknown"), + }), + ); + expect( + compareBrowserScenariosInputSchema.safeParse({ + before_scenario: { + ...consoleOnly, + scenario: { ...consoleOnly.scenario, event_families: ["dialogs"] }, + }, + after_scenario: consoleOnly, + }).success, + ).toBe(false); + + const quiet = browserScenarioCaptureSchema.parse({ + ...withFamilies(consoleOnly, []), + events: { retained: 0, dropped: 0, items: [] }, + }); + const differentEvents = compare(quiet, widened); + expect(differentEvents.overall_status).toBe("unknown"); + expect(eventDiffs(differentEvents).map(({ status }) => status)).toEqual([ + "unknown", + "unknown", + ]); + + const both = eventDiffs(compare(networkCapture(1, 0), consoleOnly)); + expect(both).toContainEqual( + expect.objectContaining({ + reason: expect.stringMatching( + /Event family selections differ.*content selections differ/u, + ), + }), + ); +}); + describe("browser scenario comparison", () => { it("records canonical literal normalization and ignores declared volatile fields", () => { const before = scenarioCapture({ @@ -485,6 +574,7 @@ const scenarioCapture = ( response_body: false, header_values: false, }, + event_families: ["console"], }, duration_ms: options.elapsedMs ?? 10, steps: [ diff --git a/src/domain/browserScenarioDiff.ts b/src/domain/browserScenarioDiff.ts index acfe3dd63f..2cb761a76b 100644 --- a/src/domain/browserScenarioDiff.ts +++ b/src/domain/browserScenarioDiff.ts @@ -5,6 +5,7 @@ import { artifactEvidenceState, captured, eventEvidenceState, + eventFamilyCoverageReason, eventsFor, networkContentCoverageReason, screenshotProjection, @@ -332,7 +333,7 @@ const compareStepArtifacts = ( ), beforeState: eventEvidenceState(input.beforeCapture, input.beforeStep), afterState: eventEvidenceState(input.afterCapture, input.afterStep), - incomparableReason: networkContentCoverageReason( + incomparableReason: eventCoverageReason( input.beforeCapture, input.afterCapture, ), @@ -340,6 +341,17 @@ const compareStepArtifacts = ( ]; }; +const eventCoverageReason = ( + before: BrowserScenarioCapture, + after: BrowserScenarioCapture, +): string | undefined => { + const reasons = [ + eventFamilyCoverageReason(before, after), + networkContentCoverageReason(before, after), + ].filter((reason) => reason !== undefined); + return reasons.length === 0 ? undefined : reasons.join(" "); +}; + interface CompareCapturedValuesInput { readonly artifact: BrowserScenarioArtifactKind; readonly before: CaptureState; diff --git a/src/domain/browserScenarioDiffEvidence.ts b/src/domain/browserScenarioDiffEvidence.ts index 015da4bff9..f7f4b45e3a 100644 --- a/src/domain/browserScenarioDiffEvidence.ts +++ b/src/domain/browserScenarioDiffEvidence.ts @@ -130,6 +130,26 @@ export const networkContentCoverageReason = ( : undefined; }; +/** Different or unrecorded event-family selection is missing comparability, not an observed website change. */ +export const eventFamilyCoverageReason = ( + before: BrowserScenarioCapture, + after: BrowserScenarioCapture, +): string | undefined => { + const left = before.scenario.event_families; + const right = after.scenario.event_families; + if (left === undefined || right === undefined) + return "Event family selection is unknown in a legacy capture; capture both sides with matching event families before comparing event evidence."; + const leftFamilies = new Set(left); + const rightFamilies = new Set(right); + return leftFamilies.size === rightFamilies.size && + [...leftFamilies].every((family) => rightFamilies.has(family)) + ? undefined + : `Event family selections differ (${familyList(leftFamilies)} before, ${familyList(rightFamilies)} after); capture both sides with matching event families before comparing event evidence.`; +}; + +const familyList = (families: ReadonlySet): string => + families.size === 0 ? "none" : [...families].sort().join(", "); + /** Wrap one value as captured comparison evidence. */ export const captured = (value: Value): CaptureState => ({ state: "captured", diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 544d4c222f..b0344ae0ec 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -5,6 +5,7 @@ import { IDENTIFIER_PATTERN } from "./stringPatterns.js"; import { localPathStringSchema } from "./localPath.js"; import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js"; +import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js"; import { browserEndpointSchema, @@ -289,20 +290,7 @@ export const browserScenarioCaptureSchema = z .strictObject({ after_each_step: z.array(snapshotKindSchema).default([]), at_end: z.array(snapshotKindSchema).default(["url"]), - events: z - .array( - z.enum([ - "console", - "page-errors", - "network", - "websockets", - "frames", - "workers", - "popups", - "downloads", - ]), - ) - .default([]), + events: z.array(browserScenarioEventFamilySchema).default([]), network: browserNetworkContentSelectionSchema, }) .default({ diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index 9084e87a95..7b2cb93ba0 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -352,6 +352,7 @@ const completeScenarioCapture = () => { response_body: false, header_values: false, }, + event_families: [], }, duration_ms: 1, steps: [ diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index c6bfdd6e12..8e086ed06e 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -85,6 +85,7 @@ const captureFor = (scenario: BrowserScenario): BrowserScenarioCapture => { action_count: scenario.actions.length, secret_references: scenario.secrets.map(({ secret_id: id }) => id), network_content: scenario.capture.network, + event_families: scenario.capture.events, }, duration_ms: 2, steps: [ diff --git a/tests/fixtures/webScriptCapture.ts b/tests/fixtures/webScriptCapture.ts index 4d3a2ff645..0c6318531c 100644 --- a/tests/fixtures/webScriptCapture.ts +++ b/tests/fixtures/webScriptCapture.ts @@ -105,6 +105,7 @@ export const scriptScenarioFixture = ( response_body: true, header_values: false, }, + event_families: ["network"], }, duration_ms: 2, steps: [step(0), step(1)], From e20994a8ea10609d420e958bd8a1268b0bcd8899 Mon Sep 17 00:00:00 2001 From: Petru101 <43178362+Petru101@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:46:00 +0100 Subject: [PATCH 32/93] fix(ghidra): keep JVM warnings out of LaunchSupport output (#1698) * fix(ghidra): keep JVM warnings out of LaunchSupport output Co-Authored-By: Claude Opus 5.5 * fix(ghidra): retain LaunchSupport warnings in session diagnostics * fix(ghidra): select helper logging flags by observed JVM identity * fix(ghidra): preserve diagnostics through typed failure projection --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/domain/analysisErrorBase.ts | 9 + src/domain/analysisErrorProjection.test.ts | 44 ++- src/domain/analysisErrorProjection.ts | 6 +- src/domain/explicitSensitiveFailure.test.ts | 60 +++ src/domain/explicitSensitiveFailure.ts | 69 ++-- src/domain/providerAdapterError.ts | 7 - src/ghidra/GhidraDiagnostics.ts | 7 + src/ghidra/GhidraInstallation.ts | 9 + src/ghidra/GhidraJavaLaunch.ts | 11 + src/ghidra/GhidraLauncher.ts | 76 +++- src/ghidra/GhidraProvider.behavior.test.ts | 16 +- src/ghidra/GhidraProviderClient.ts | 30 +- src/ghidra/GhidraSessionError.ts | 22 +- .../filesystem/ghidraJavaProbe.test.ts | 19 +- .../providers/ghidra/ghidraJavaLaunch.test.ts | 345 ++++++++++++++++++ 15 files changed, 646 insertions(+), 84 deletions(-) create mode 100644 tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index ff55627049..43f39f6279 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,3 +1,5 @@ +import type { JsonValue } from "./jsonValue.js"; + /** Stable tags exposed by safe analysis-error projections. */ const ANALYSIS_ERROR_TAGS = [ "AnalysisProtocolError", @@ -51,6 +53,8 @@ export interface AnalysisCleanupObservation { /** Optional provider-neutral context that must survive typed error projection. */ export interface AnalysisErrorOptions extends ErrorOptions { + /** Producer-owned, caller-safe facts retained through every typed projection. */ + readonly diagnostics?: Readonly> | undefined; readonly capturedOutput?: AnalysisCapturedOutput | undefined; readonly cleanup?: AnalysisCleanupObservation | undefined; readonly partialObservation?: AnalysisPartialObservation | undefined; @@ -58,12 +62,17 @@ export interface AnalysisErrorOptions extends ErrorOptions { /** Base class for expected analysis, provider, and session failures. */ export abstract class AnalysisError extends Error { + readonly diagnostics: Readonly> | undefined; readonly capturedOutput: AnalysisCapturedOutput | undefined; readonly cleanup: AnalysisCleanupObservation | undefined; readonly cleanupIncomplete: boolean; readonly cleanupResources: readonly string[]; constructor(message: string, options?: AnalysisErrorOptions) { super(message, options); + this.diagnostics = + options?.diagnostics === undefined + ? undefined + : structuredClone(options.diagnostics); this.capturedOutput = options?.capturedOutput === undefined ? undefined diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index b166385a08..467704b980 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -403,25 +403,33 @@ describe("analysis error projection: provider failures", () => { }); }); - it("preserves detached, actionable provider diagnostics", () => { - const diagnostics = { - runtime_root: "/tmp/rea-ghidra-fixture", - profile_digest: "a".repeat(64), - exit_code: 1, - }; - const error = new ProviderAdapterError("ghidra", "health", { - diagnostics, - }); + it.each(["adapter", "cancelled", "timeout"])( + "preserves detached, actionable diagnostics for %s errors", + (kind) => { + const diagnostics = { + runtime_root: "/tmp/rea-ghidra-fixture", + profile_digest: "a".repeat(64), + exit_code: 1, + }; + const error = + kind === "adapter" + ? new ProviderAdapterError("ghidra", "health", { diagnostics }) + : kind === "cancelled" + ? new AnalysisCancelledError("health", { diagnostics }) + : new AnalysisTimeoutError("health", 250, { diagnostics }); - expect(error.diagnostics).not.toBe(diagnostics); - expect(projectAnalysisError(error)).toMatchObject({ - details: { - provider_id: "ghidra", - operation: "health", - diagnostics, - }, - }); - }); + expect(error.diagnostics).not.toBe(diagnostics); + expect(projectAnalysisError(error)).toMatchObject({ + details: { + ...(kind === "adapter" ? { provider_id: "ghidra" } : {}), + operation: "health", + diagnostics, + }, + }); + diagnostics.exit_code = 2; + expect(error.diagnostics?.exit_code).toBe(1); + }, + ); it("maps the Linux startup compatibility exit codes without host internals", () => { const expected = [ diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index cd0c00987f..723200e181 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -52,6 +52,9 @@ export const projectAnalysisError = ( const primaryDetails = errorDetails(error); const details = { ...primaryDetails, + ...(error.diagnostics === undefined + ? {} + : { diagnostics: error.diagnostics }), ...(error.cleanupIncomplete ? { cleanup: "incomplete", @@ -358,9 +361,6 @@ const providerErrorDetails = ( resources: [...error.cleanupResources], } : {}), - ...(error.diagnostics === undefined - ? {} - : { diagnostics: error.diagnostics }), }; if (error instanceof BrowserObservationError) { const primaryReason = primaryBrowserFailureReason(error); diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index 16dbea9ef6..68aaf9f8f1 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -3,14 +3,74 @@ import { expect, it } from "vitest"; import { AnalysisInputError, AnalysisCapabilityUnavailableError, + AnalysisCancelledError, + AnalysisTimeoutError, + AnalysisArtifactChangedError, + AnalysisOutputError, + AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { projectAnalysisError } from "./analysisErrorProjection.js"; import { ProviderCleanupError } from "./providerCleanupError.js"; +import { ProviderAdapterError } from "./providerAdapterError.js"; import { redactExplicitFailure, redactExplicitPointer, } from "./explicitSensitiveFailure.js"; +const context = { + diagnostics: { + note: "explicit-secret warning", + nested: { "explicit-secret-key": 5, observed: 7 }, + caller_path: "/tmp/password-fixture?token=public-value", + }, + capturedOutput: { + stdout: "explicit-secret output", + stderr: "public stderr", + stdout_bytes: 22, + truncated: false, + }, +}; + +it.each([ + new AnalysisCancelledError("inspect", context), + new AnalysisTimeoutError("inspect", 250, context), + new AnalysisInputError("inspect", context), + new AnalysisAccessDeniedError("inspect", "/public", "EACCES", context), + new AnalysisArtifactChangedError("inspect", "/public", "changed", context), + new AnalysisOutputError("inspect", "invalid", context), + new AnalysisCapabilityUnavailableError( + "provider", + "inspect", + "unsupported", + context, + ), + new AnalysisResourceConstraintError( + "inspect", + "memory", + "capacity", + null, + context, + ), + new ProviderAdapterError("provider", "inspect", context), +])("preserves sanitized common context for $._tag", (error) => { + const sanitized = redactExplicitFailure(error, ["explicit-secret"]); + expect(sanitized._tag).toBe(error._tag); + const projected = projectAnalysisError(sanitized); + expect(projected.details).toMatchObject({ + diagnostics: { + note: "[REDACTED] warning", + nested: { observed: 7 }, + caller_path: context.diagnostics.caller_path, + }, + captured_output: { + stdout: "[REDACTED] output", + stdout_bytes: 22, + stderr: "public stderr", + }, + }); + expect(JSON.stringify(projected)).not.toContain("explicit-secret"); +}); + it.each([ ["/metadata/private~1~0/numeric", ["private/~"], "/metadata"], ["/metadata/private/child", ["private"], "/metadata"], diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index dd7a14366a..05a4f2f888 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -6,6 +6,8 @@ import { AnalysisOutputError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + AnalysisCancelledError, + AnalysisTimeoutError, } from "./analysisErrorCore.js"; import { redactExplicitText } from "./explicitSensitiveValues.js"; import type { JsonValue } from "./jsonValue.js"; @@ -72,24 +74,57 @@ export const redactExplicitFailure = ( if (typeof value === "object" && value !== null) return diagnostics(value); return value; }; + const context = { + cause: error, + ...(error.diagnostics === undefined + ? {} + : { diagnostics: diagnostics(error.diagnostics) }), + ...(error.capturedOutput === undefined + ? {} + : { + capturedOutput: { + ...error.capturedOutput, + stdout: text(error.capturedOutput.stdout), + stderr: text(error.capturedOutput.stderr), + }, + }), + ...(error.cleanup === undefined + ? {} + : { + cleanup: { + reason: text(error.cleanup.reason), + resources: error.cleanup.resources.map(text), + }, + }), + }; + if (error instanceof AnalysisCancelledError) + return new AnalysisCancelledError(error.operation, { + ...context, + partialObservation: error.partialObservation, + }); + if (error instanceof AnalysisTimeoutError) + return new AnalysisTimeoutError(error.operation, error.timeoutMs, { + ...context, + partialObservation: error.partialObservation, + }); if (error instanceof AnalysisAccessDeniedError) return new AnalysisAccessDeniedError( error.operation, text(error.path), error.systemCode, - { cause: error }, + context, ); if (error instanceof AnalysisArtifactChangedError) return new AnalysisArtifactChangedError( error.operation, text(error.path), text(error.reason), - { cause: error }, + context, ); if (error instanceof AnalysisInputError) return new AnalysisInputError( error.operation, - { cause: error }, + context, error.issues.map((issue) => ({ ...issue, // Unknown-argument paths contain literal field names, including names @@ -104,16 +139,18 @@ export const redactExplicitFailure = ( })), ); if (error instanceof AnalysisOutputError) - return new AnalysisOutputError(error.operation, text(error.reason), { - cause: error, - }); + return new AnalysisOutputError( + error.operation, + text(error.reason), + context, + ); if (error instanceof AnalysisCapabilityUnavailableError) return new AnalysisCapabilityUnavailableError( error.providerId, error.operation, text(error.reason), { - cause: error, + ...context, ...(error.userMessage === undefined ? {} : { userMessage: text(error.userMessage) }), @@ -126,19 +163,10 @@ export const redactExplicitFailure = ( text(error.reason), error.reportedLimits === null ? null : diagnostics(error.reportedLimits), { - cause: error, + ...context, ...(error.remediationAction === undefined ? {} : { remediationAction: text(error.remediationAction) }), - ...(error.capturedOutput === undefined - ? {} - : { - capturedOutput: { - stdout: text(error.capturedOutput.stdout), - stderr: text(error.capturedOutput.stderr), - truncated: error.capturedOutput.truncated, - }, - }), }, ); if (error instanceof ProviderCleanupError) @@ -149,11 +177,6 @@ export const redactExplicitFailure = ( { operation: error.operation, cause: error }, ); if (error instanceof ProviderAdapterError) - return new ProviderAdapterError(error.providerId, error.operation, { - cause: error, - ...(error.diagnostics === undefined - ? {} - : { diagnostics: diagnostics(error.diagnostics) }), - }); + return new ProviderAdapterError(error.providerId, error.operation, context); return error; }; diff --git a/src/domain/providerAdapterError.ts b/src/domain/providerAdapterError.ts index f434803c5b..02bb178a8e 100644 --- a/src/domain/providerAdapterError.ts +++ b/src/domain/providerAdapterError.ts @@ -1,4 +1,3 @@ -import type { JsonValue } from "./jsonValue.js"; import { AnalysisError, type AnalysisErrorOptions, @@ -7,14 +6,12 @@ import { /** A provider adapter failed outside its more precise typed variants. */ export interface ProviderAdapterErrorOptions extends AnalysisErrorOptions { readonly userMessage?: string; - readonly diagnostics?: Readonly>; } /** A provider adapter failed outside its more precise typed variants. */ export class ProviderAdapterError extends AnalysisError { readonly _tag = "ProviderAdapterError"; override readonly userMessage: string | undefined; - readonly diagnostics: Readonly> | undefined; constructor( readonly providerId: string, @@ -23,9 +20,5 @@ export class ProviderAdapterError extends AnalysisError { ) { super(`Provider ${providerId} adapter failed during ${operation}`, options); this.userMessage = options.userMessage; - this.diagnostics = - options.diagnostics === undefined - ? undefined - : structuredClone(options.diagnostics); } } diff --git a/src/ghidra/GhidraDiagnostics.ts b/src/ghidra/GhidraDiagnostics.ts index 2530751fcd..8ec3de36d7 100644 --- a/src/ghidra/GhidraDiagnostics.ts +++ b/src/ghidra/GhidraDiagnostics.ts @@ -61,6 +61,13 @@ export const createGhidraDiagnostics = ( ghidra_log_path: options.launch.ghidraLogPath, script_log_path: options.launch.scriptLogPath, process_id: options.launch.process.pid ?? null, + ...(options.launch.launchSupport === undefined + ? {} + : { + launch_support: options.launch.launchSupport.map((output) => ({ + ...output, + })), + }), }), ...(options.snapshot === undefined ? {} diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index da762cb797..713fe820ff 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -192,6 +192,8 @@ interface GhidraInstallationObservation { readonly applicationPropertiesPath: string | null; readonly nativeDecompilerPath: string | null; readonly javaCommand: string; + /** Observed java.vm.name; omitted when the probe cannot establish VM identity. */ + readonly javaVmName?: string; readonly checks: readonly GhidraInstallationCheck[]; } @@ -232,6 +234,7 @@ export interface GhidraJavaObservation { readonly home: string; readonly bits: number; readonly runtime: "jdk" | "jre"; + readonly vmName?: string; } /** A bounded executable-header probe: a small prefix plus the whole file size. */ @@ -309,6 +312,9 @@ export const inspectGhidraInstallation = ( applicationPropertiesPath: coordinates.applicationPropertiesPath, nativeDecompilerPath: coordinates.nativeDecompilerPath, javaCommand: coordinates.javaCommand, + ...(coordinates.java?.vmName === undefined + ? {} + : { javaVmName: coordinates.java.vmName }), checks, }; if (failed !== undefined) @@ -539,6 +545,7 @@ export const ghidraInstallationDiagnostics = ( java_command: inspection.javaCommand, java_home: inspection.javaHome, java_version: inspection.javaVersion, + java_vm_name: inspection.javaVmName ?? null, platform: inspection.platform, architecture: inspection.architecture, checks: inspection.checks.map((check) => ({ @@ -756,6 +763,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`; const version = javaProperty(output, "java.version"); const home = javaProperty(output, "java.home"); + const vmName = javaProperty(output, "java.vm.name"); const bits = Number.parseInt( javaProperty(output, "sun.arch.data.model") ?? "0", 10, @@ -765,6 +773,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ version, major: javaMajor(version), home, + ...(vmName === null ? {} : { vmName }), bits, runtime: executablePath( (process.platform === "win32" ? win32 : posix).join( diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index a38fc7052f..1e34eeb082 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -7,11 +7,13 @@ export const ghidraJavaLaunch = async (options: { readonly platform: NodeJS.Platform; readonly analyzeHeadlessPath: string; readonly javaHome: string; + readonly javaVmName?: string; readonly homeRoot: string; readonly tempRoot: string; readonly arguments: readonly string[]; readonly environment: NodeJS.ProcessEnv; readonly signal?: AbortSignal; + readonly onStderr: (mode: "-envvars" | "-vmargs", stderr: string) => void; }): Promise<{ readonly command: string; readonly arguments: readonly string[]; @@ -26,6 +28,13 @@ export const ghidraJavaLaunch = async (options: { ]; const supportArguments = [ ...isolatedProperties, + // Only identified HotSpot VMs have these unified-logging semantics. OpenJ9 + // uses different -Xlog behavior; missing VM identity must stay unknown. + ...(/^(?:OpenJDK|Java HotSpot\(TM\)) (?:64-Bit )?(?:Server|Client|Zero) VM$/u.test( + options.javaVmName ?? "", + ) + ? ["-Xlog:disable", "-Xlog:all=warning:stderr"] + : []), "-cp", join(supportRoot, "LaunchSupport.jar"), "LaunchSupport", @@ -42,6 +51,7 @@ export const ghidraJavaLaunch = async (options: { ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); + options.onStderr("-envvars", configuredEnvironment.stderr); for (const line of configuredEnvironment.stdout.split(/\r?\n/u)) { if (line === "") continue; const separator = line.indexOf("="); @@ -63,6 +73,7 @@ export const ghidraJavaLaunch = async (options: { ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); + options.onStderr("-vmargs", configuredArguments.stderr); const vmArguments = configuredArguments.stdout .split(/\r?\n/u) .filter((line) => line !== ""); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 8348ee28a2..59c5556060 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -21,6 +21,8 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { execFileOutputFailure } from "../process/ExecFileOutput.js"; +import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; +import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js"; import { cleanupOwnedProcessGroup, cleanupWindowsProcessTree, @@ -62,6 +64,18 @@ export type GhidraLaunch = ProviderProcessLaunch & { readonly projectRoot: string; readonly ghidraLogPath: string; readonly scriptLogPath: string; + readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; +}; + +/** Same per-stream projection budget for helper warnings and failed commands. */ +export const GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES = 8 * 1024; + +/** Successful helper stderr, retained independently of the analysis JVM output. */ +export type GhidraLaunchSupportDiagnostic = { + readonly mode: "-envvars" | "-vmargs"; + readonly stderr: string; + readonly stderr_bytes: number; + readonly stderr_truncated: boolean; }; /** Provider-owned capability that starts one isolated headless analysis. */ @@ -76,20 +90,24 @@ export interface GhidraLauncher { export class GhidraLaunchError extends Error { readonly #partialLaunch: GhidraLaunch | undefined; readonly #capturedOutput: AnalysisCapturedOutput | undefined; + readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[]; constructor( message: string, options?: ErrorOptions & { readonly partialLaunch?: GhidraLaunch; readonly capturedOutput?: AnalysisCapturedOutput; + readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; }, ) { - const { partialLaunch, capturedOutput, ...errorOptions } = options ?? {}; + const { partialLaunch, capturedOutput, launchSupport, ...errorOptions } = + options ?? {}; super(message, errorOptions); this.name = "GhidraLaunchError"; this.#partialLaunch = partialLaunch; this.#capturedOutput = capturedOutput === undefined ? undefined : { ...capturedOutput }; + this.launchSupport = (launchSupport ?? []).map((output) => ({ ...output })); } /** Process ownership retained for the client to retry an incomplete rollback. */ @@ -129,6 +147,7 @@ export interface GhidraHeadlessLauncherOptions { readonly environment: Readonly; readonly analyzeHeadlessPath: string; readonly javaHome?: string; + readonly javaVmName?: string; readonly bridgeScriptPath: string; readonly platform?: NodeJS.Platform; readonly comSpec?: string; @@ -173,6 +192,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { const paths = ghidraRuntimePaths(session.runtimeRoot); const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; + const launchSupport: GhidraLaunchSupportDiagnostic[] = []; try { const psp = this.options.pspExtension; if (psp !== undefined) { @@ -284,10 +304,32 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, analyzeHeadlessPath: this.options.analyzeHeadlessPath, javaHome: this.options.javaHome, + ...(this.options.javaVmName === undefined + ? {} + : { javaVmName: this.options.javaVmName }), homeRoot: paths.homeRoot, tempRoot: paths.tempRoot, arguments: headlessArguments, environment, + onStderr: (mode, stderr) => { + if (stderr === "") return; + const bytes = Buffer.byteLength(stderr); + const redacted = redactCapturedTransportCredential( + { text: stderr, bytes, observedBytes: bytes }, + session.token, + "[REDACTED]", + ); + const bounded = boundedUtf8Text( + redacted, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + launchSupport.push({ + mode, + stderr: bounded.text, + stderr_bytes: bytes, + stderr_truncated: bounded.truncated, + }); + }, ...(options.signal === undefined ? {} : { signal: options.signal }), @@ -306,7 +348,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { hostEnvironment: {}, ...(options.signal === undefined ? {} : { signal: options.signal }), }); - const partialLaunch = ownedGhidraLaunch(started, paths, platform); + const partialLaunch = ownedGhidraLaunch( + started, + paths, + platform, + launchSupport, + ); await writeGhidraRuntimeFile( paths.ownershipPath, `${JSON.stringify({ @@ -330,12 +377,17 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { return ok(partialLaunch); } catch (cause: unknown) { const capturedOutput = ghidraLaunchCommandOutput(cause); + const launchFailure = new GhidraLaunchError( + "Ghidra headless launch failed", + { + cause, + launchSupport, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }, + ); const primary = isAborted(options.signal) - ? new AnalysisCancelledError("open_binary", { cause }) - : new GhidraLaunchError("Ghidra headless launch failed", { - cause, - ...(capturedOutput === undefined ? {} : { capturedOutput }), - }); + ? new AnalysisCancelledError("open_binary", { cause: launchFailure }) + : launchFailure; if (started === undefined) return err(primary); let cleanupFailure: unknown; @@ -349,7 +401,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { cleanupFailure = failure; } if (cleanupFailure !== undefined) { - const partialLaunch = ownedGhidraLaunch(started, paths, platform); + const partialLaunch = ownedGhidraLaunch( + started, + paths, + platform, + launchSupport, + ); const primaryMessage = isAborted(options.signal) ? primary.message : `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`; @@ -363,6 +420,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { { cause: primary }, ), partialLaunch, + launchSupport, ...(capturedOutput === undefined ? {} : { capturedOutput }), }, ), @@ -377,11 +435,13 @@ const ownedGhidraLaunch = ( spawned: SpawnedOwnedProviderProcess, paths: ReturnType, platform: NodeJS.Platform, + launchSupport: readonly GhidraLaunchSupportDiagnostic[], ): GhidraLaunch => ({ ...ownedGhidraProcess(spawned, platform), projectRoot: paths.projectRoot, ghidraLogPath: paths.ghidraLogPath, scriptLogPath: paths.scriptLogPath, + ...(launchSupport.length === 0 ? {} : { launchSupport: [...launchSupport] }), }); const ownedGhidraProcess = ( diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index a3b2ccf2ae..8b9c254618 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -46,6 +46,7 @@ const installationHost = (): GhidraInstallationHost => ({ home: "/usr/lib/jvm/jdk-21", bits: 64, runtime: "jdk", + vmName: "OpenJDK 64-Bit Server VM", }), }); @@ -599,6 +600,11 @@ describe("Ghidra client projection", () => { expect.objectContaining({ runId: "11111111-1111-4111-8111-111111111111", platform: "linux", + launcher: expect.objectContaining({ + options: expect.objectContaining({ + javaVmName: "OpenJDK 64-Bit Server VM", + }), + }), }), ]); expect(toolCalls).toEqual([ @@ -756,6 +762,11 @@ describe("Ghidra result projection", () => { ok: false, error: { _tag: tag }, }); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { diagnostics: { remote_code: code } }, + }); }); it("returns cancellation before profile work", async () => { @@ -1691,7 +1702,10 @@ it("projects Ghidra startup failure and its incomplete cleanup together", async diagnostics: { primary_error: { code: "provider_timeout", - details: { timeout_ms: 250 }, + details: { + timeout_ms: 250, + diagnostics: { failure_kind: "timeout" }, + }, }, cleanup_error: { code: "cleanup_incomplete" }, }, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 39c89c46ae..5a4f4eafd2 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -541,6 +541,9 @@ export const createGhidraProviderClient = (input: { environment: input.environment, analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, javaHome: prerequisites.value.javaHome, + ...(installation.javaVmName === undefined + ? {} + : { javaVmName: installation.javaVmName }), bridgeScriptPath: fileURLToPath( new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), @@ -980,37 +983,40 @@ const projectPrimarySessionError = ( failure: GhidraSessionError, startupTimeoutMs: number, ): AnalysisError => { + const context = { cause: failure, diagnostics: failure.diagnostics }; if (failure.cause instanceof AnalysisAccessDeniedError) return new AnalysisAccessDeniedError( operation, failure.cause.path, failure.cause.systemCode, - { cause: failure }, + context, ); if (failure.cause instanceof AnalysisArtifactChangedError) return new AnalysisArtifactChangedError( operation, failure.cause.path, failure.cause.reason, - { cause: failure }, + context, ); if ( operation === "annotate_native_function" && failure.kind === "remote" && failure.remoteCode === "invalid_function_name" ) - return new AnalysisInputError(operation, { cause: failure }, [ + return new AnalysisInputError(operation, context, [ { path: ["name"], reason: "invalid_value", message: failure.message }, ]); - if (failure.kind === "cancelled") - return new AnalysisCancelledError(operation); + if ( + failure.kind === "cancelled" || + (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") + ) + return new AnalysisCancelledError(operation, context); if (failure.kind === "timeout" || failure.kind === "analysis_timeout") return new AnalysisTimeoutError( operation, failure.timeoutMs ?? startupTimeoutMs, + context, ); - if (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") - return new AnalysisCancelledError(operation); if ( failure.kind === "remote" && failure.remoteCode === "regex_stack_exhausted" @@ -1021,7 +1027,7 @@ const projectPrimarySessionError = ( failure.message, null, { - cause: failure, + ...context, remediationAction: "Retry this search in literal mode or simplify the regex. The active analysis session and annotations remain available.", }, @@ -1032,7 +1038,7 @@ const projectPrimarySessionError = ( failure.remoteCode ?? "", ) ) - return new AnalysisInputError(operation, { cause: failure }, [ + return new AnalysisInputError(operation, context, [ { path: [], reason: "invalid_value", message: failure.message }, ]); if (failure.kind === "remote" && failure.remoteCode === "method_unavailable") @@ -1040,9 +1046,7 @@ const projectPrimarySessionError = ( "ghidra", operation, failure.message, + context, ); - return new ProviderAdapterError("ghidra", operation, { - cause: failure, - diagnostics: failure.diagnostics, - }); + return new ProviderAdapterError("ghidra", operation, context); }; diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index cc76ab3383..ca3b4f6aca 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -2,13 +2,15 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; -import { GhidraLaunchError } from "./GhidraLauncher.js"; +import { + GhidraLaunchError, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, +} from "./GhidraLauncher.js"; const MAX_CAUSE_DEPTH = 3; const MAX_CAUSE_MESSAGE_BYTES = 2 * 1024; const MAX_CAUSE_FIELD_BYTES = 1024; const MAX_COMPILER_STDERR_BYTES = 8 * 1024; -const MAX_LAUNCH_COMMAND_OUTPUT_BYTES = 8 * 1024; /** Stable failure phase for one private Ghidra headless session. */ export type GhidraSessionFailureKind = @@ -229,18 +231,28 @@ const causeLaunchOutcome = ( redact: (value: string) => string, ): Record => { if (!(cause instanceof GhidraLaunchError)) return {}; + const launchSupport = + cause.launchSupport.length === 0 + ? {} + : { + launch_support: cause.launchSupport.map((output) => ({ + ...output, + stderr: redact(output.stderr), + })), + }; const output = cause.capturedOutput; - if (output === undefined) return {}; + if (output === undefined) return launchSupport; const stdout = boundedUtf8Text( redact(output.stdout), - MAX_LAUNCH_COMMAND_OUTPUT_BYTES, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, ); const stderr = boundedUtf8Text( redact(output.stderr), - MAX_LAUNCH_COMMAND_OUTPUT_BYTES, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, ); const truncated = output.truncated || stdout.truncated || stderr.truncated; return { + ...launchSupport, launch_outcome: { stdout: stdout.text, stderr: stderr.text, diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts index 82ed00ef76..c0a6974d5e 100644 --- a/tests/boundary/filesystem/ghidraJavaProbe.test.ts +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -2,7 +2,10 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; -import { inspectGhidraInstallation } from "../../../src/ghidra/GhidraInstallation.js"; +import { + inspectGhidraInstallation, + ghidraInstallationDiagnostics, +} from "../../../src/ghidra/GhidraInstallation.js"; import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraInstallationPolicy.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -16,12 +19,12 @@ vi.mock("node:child_process", async (importOriginal) => ({ afterEach(() => mockSpawnSync.mockReset()); it.each([ - { major: 17, fullJdk: true }, - { major: 21, fullJdk: true }, - { major: 21, fullJdk: false }, + { major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" }, + { major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" }, + { major: 21, fullJdk: false, vmName: undefined }, ])( "preserves the reported Unicode Java home and classifies JDK $major (full JDK: $fullJdk)", - async ({ major, fullJdk }) => { + async ({ major, fullJdk, vmName }) => { const root = await createTestTempDirectory("rea-java-probe-"); const configuredHome = join(root, "selected-jdk"); const reportedHome = join(root, "Réa逆向", `JDK ${String(major)}`); @@ -46,7 +49,7 @@ it.each([ const version = `${String(major)}.0.11`; mockSpawnSync.mockReturnValue({ stdout: "", - stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n`, + stderr: ` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`, status: 0, }); @@ -58,6 +61,10 @@ it.each([ expect(inspection.javaHome).toBe(reportedHome); expect(inspection.javaVersion).toBe(version); + expect(inspection.javaVmName).toBe(vmName); + expect(ghidraInstallationDiagnostics(inspection).java_vm_name).toBe( + vmName ?? null, + ); expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject( { status: fullJdk ? "passed" : "failed", diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts new file mode 100644 index 0000000000..dc4be5ef20 --- /dev/null +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -0,0 +1,345 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { describe, expect, it, onTestFinished } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { createGhidraDiagnostics } from "../../../../src/ghidra/GhidraDiagnostics.js"; +import { + GhidraHeadlessLauncher, + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + type GhidraLaunchSession, +} from "../../../../src/ghidra/GhidraLauncher.js"; +import { bindGhidraSessionFailure } from "../../../../src/ghidra/GhidraSessionError.js"; +import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { waitForAbortableDelay } from "../../../../src/process/ProviderDeadline.js"; +import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js"; +import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { parseConfig } from "../../../../src/config/parseConfig.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { analysisErrorProjectionSchema } from "../../../../src/contracts/errorSchemas.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; +import { elf } from "../../../../src/domain/binaryTarget.fixture.js"; +import { silentLogger } from "../../../../src/logger.js"; + +const warning = + "[0.004s][warning][os,container] Cgroup limits may be inaccurate: /tmp/password-fixture?token=public-value"; + +const fixture = async ( + mode: string, + javaVmName: string | null = "OpenJDK 64-Bit Server VM", +) => { + const root = await createTestTempDirectory("rea-ghidra-java-launch-"); + const javaHome = join(root, "jdk"); + await mkdir(join(javaHome, "bin"), { recursive: true }); + await writeFile( + join(javaHome, "bin", "java"), + `#!/usr/bin/env node +const args = process.argv.slice(2); +const probe = args.at(-1); +if (probe !== "-envvars" && probe !== "-vmargs") process.exit(64); +const mode = ${JSON.stringify(mode)}; +if (mode === "non-hotspot" && args.some(arg => arg.startsWith("-Xlog:"))) process.exit(65); +const warning = ${JSON.stringify(warning)}; +const stream = args.includes("-Xlog:disable") && args.includes("-Xlog:all=warning:stderr") ? process.stderr : process.stdout; +if (mode !== "quiet" && mode !== "non-hotspot") stream.write(probe + ": " + warning + "\\n"); +if (mode === "credential") stream.write("explicit credential: private-transport-token\\n"); +if (mode === "large") stream.write("é".repeat(${GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES}) + "UNRETAINED-TAIL\\n"); +if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { + process.stderr.write("failed " + probe + "\\n"); + process.exitCode = 3; +} else if (probe === "-envvars") { + process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n"); +} else { + process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n"); +} +`, + { mode: 0o755 }, + ); + const session: GhidraLaunchSession = { + runtimeRoot: join(root, "runtime"), + transport: "unix-socket", + endpointPath: join(root, "endpoint.sock"), + token: "private-transport-token", + runId: randomUUID(), + targetPath: join(root, "target.bin"), + targetSha256: "0".repeat(64), + providerVersion: "test", + profileDigest: "test", + }; + const analysisArguments: (readonly string[])[] = []; + const controller = new AbortController(); + const launcher = new GhidraHeadlessLauncher({ + environment: { PATH: process.env.PATH ?? "" }, + platform: process.platform, + javaHome, + ...(javaVmName === null ? {} : { javaVmName }), + analyzeHeadlessPath: join(root, "ghidra", "support", "analyzeHeadless"), + bridgeScriptPath: join(root, "ReaGhidraBridge.java"), + spawnProcess: async (options) => { + analysisArguments.push(options.arguments); + const spawned = await spawnOwnedProviderProcess({ + ...options, + command: process.execPath, + arguments: ["-e", "setInterval(() => {}, 1000)"], + expectedCommand: process.execPath, + }); + onTestFinished(async () => { + const cleanup = + spawned.cleanup === undefined + ? await cleanupOwnedProcessGroup(spawned.ownership) + : await spawned.cleanup(); + expect(cleanup.cleaned).toBe(true); + }); + if (mode === "cancel-after-spawn") controller.abort(); + if (mode === "timeout-after-spawn") + await waitForAbortableDelay(10_000, options.signal); + return spawned; + }, + }); + return { session, launcher, analysisArguments, controller }; +}; + +describe.skipIf(process.platform === "win32")( + "Ghidra LaunchSupport JVM compatibility", + () => { + it.each(["Eclipse OpenJ9 VM", "unrecognized VM", ""])( + "keeps HotSpot-only flags off a VM identified as %s", + async (javaVmName) => { + const { session, launcher, analysisArguments } = await fixture( + "non-hotspot", + javaVmName === "" ? null : javaVmName, + ); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(analysisArguments).toHaveLength(1); + expect(launched.value.launchSupport).toBeUndefined(); + }, + ); + + it.each([ + "OpenJDK 64-Bit Server VM", + "Java HotSpot(TM) 64-Bit Server VM", + "OpenJDK 64-Bit Zero VM", + ])( + "preserves redirected warnings for %s while keeping the analysis JVM arguments clean", + async (javaVmName) => { + const { session, launcher, analysisArguments } = await fixture( + "success", + javaVmName, + ); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(analysisArguments[0]).toContain("-Dfile.encoding=UTF8"); + expect(analysisArguments[0]).toContain("-Xmx1G"); + expect( + analysisArguments[0]?.some( + (argument) => + argument.startsWith("-Xlog:") || argument.includes("[warning]"), + ), + ).toBe(false); + const diagnostics = createGhidraDiagnostics({ + targetPath: session.targetPath, + targetSha256: session.targetSha256, + transport: session.transport, + providerVersion: session.providerVersion, + profileDigest: session.profileDigest, + launch: launched.value, + token: session.token, + previous: {}, + }); + expect(diagnostics.launch_support).toEqual( + ["-envvars", "-vmargs"].map((mode) => ({ + mode, + stderr: `${mode}: ${warning}\n`, + stderr_bytes: Buffer.byteLength(`${mode}: ${warning}\n`), + stderr_truncated: false, + })), + ); + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra LaunchSupport diagnostics", + () => { + it.each(["env-failure", "vm-failure", "env-malformed", "vm-malformed"])( + "retains helper warnings through session failure diagnostics after %s", + async (mode) => { + const { session, launcher, analysisArguments } = await fixture(mode); + const launched = await launcher.launch(session); + expect(launched.ok).toBe(false); + if (launched.ok) throw new Error("Expected a helper failure"); + expect(analysisArguments).toEqual([]); + const failure = bindGhidraSessionFailure(() => ({}))( + "start", + launched.error.message, + launched.error, + ); + const cause = failure.diagnostics.failure_cause; + if (mode === "env-failure") { + expect(cause).toMatchObject({ + launch_outcome: { + exit_code: 3, + stderr: expect.stringContaining(`-envvars: ${warning}`), + }, + }); + } else { + expect(cause).toMatchObject({ + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-envvars", + stderr: `-envvars: ${warning}\n`, + }), + ]), + }); + } + if (mode === "vm-failure") + expect(cause).toMatchObject({ + launch_outcome: { + exit_code: 3, + stderr: expect.stringContaining(`-vmargs: ${warning}`), + }, + }); + if (mode === "vm-malformed") + expect(cause).toMatchObject({ + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-vmargs", + stderr: `-vmargs: ${warning}\n`, + }), + ]), + }); + }, + ); + + it("bounds retained UTF-8 warnings and reports original byte counts", async () => { + const { session, launcher } = await fixture("large"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + for (const output of launched.value.launchSupport ?? []) { + expect(output.stderr_truncated).toBe(true); + expect(output.stderr_bytes).toBeGreaterThan( + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + expect(Buffer.byteLength(output.stderr)).toBeLessThanOrEqual( + GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, + ); + expect(output.stderr).not.toContain("UNRETAINED-TAIL"); + expect(output.stderr).not.toContain("�"); + } + expect(launched.value.launchSupport).toHaveLength(2); + }); + + it("omits helper diagnostics when both stderr streams are empty", async () => { + const { session, launcher } = await fixture("quiet"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(launched.value.launchSupport).toBeUndefined(); + }); + + it("redacts the explicit transport credential and preserves caller-selected paths and URL values", async () => { + const { session, launcher } = await fixture("credential"); + const launched = await launcher.launch(session); + if (!launched.ok) throw launched.error; + expect(launched.value.launchSupport).toHaveLength(2); + for (const output of launched.value.launchSupport ?? []) { + expect(output.stderr).toContain(warning); + expect(output.stderr).toContain("explicit credential: [REDACTED]"); + expect(output.stderr).not.toContain(session.token); + expect(output.stderr_truncated).toBe(false); + } + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra startup interruption diagnostics", + () => { + it.each(["cancelled", "timeout"] as const)( + "preserves helper warnings through the provider-neutral %s projection", + async (kind) => { + const { session, launcher, controller } = await fixture( + kind === "cancelled" ? "cancel-after-spawn" : "timeout-after-spawn", + ); + const bytes = elf(2, 1, 62); + await writeFile(session.targetPath, bytes); + const target: BinaryTarget = { + path: session.targetPath, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + const config = parseConfig({ + GHIDRA_INSTALL_DIR: "/fixture/ghidra", + REA_GHIDRA_STARTUP_TIMEOUT_MS: "3000", + }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + { + platform: process.platform, + architecture: process.arch, + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.12", + major: 21, + home: "/fixture/jdk", + bits: 64, + runtime: "jdk", + }), + }, + (options) => new GhidraClient({ ...options, launcher }), + ); + const profile = await provider.resolveAnalysisProfile(target); + if (!profile.ok) throw profile.error; + if (profile.value.profile === null) + throw new Error("Expected Ghidra profile"); + const client = provider.createClient(target, profile.value.profile); + onTestFinished(async () => { + expect((await client.close()).ok).toBe(true); + }); + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected interrupted startup"); + const projected = analysisErrorProjectionSchema.parse( + JSON.parse(JSON.stringify(projectAnalysisError(result.error))), + ); + expect(projected.code).toBe( + kind === "cancelled" ? "cancelled" : "provider_timeout", + ); + expect(projected).toMatchObject({ + details: { + diagnostics: { + failure_cause: { + cause: { + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-envvars", + stderr: `-envvars: ${warning}\n`, + }), + expect.objectContaining({ + mode: "-vmargs", + stderr: `-vmargs: ${warning}\n`, + }), + ]), + }, + }, + }, + }, + }); + }, + ); + }, +); From 0365454a573eb52c7089320fd74bfd4a673b78ae Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:46:42 +0800 Subject: [PATCH 33/93] docs(agents): require brittleness review before resolving threads --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7849a33eb7..16a586b183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ Assess brittleness in touched code during implementation, debugging, review, and When fixing a bug, inspect the producing boundary and affected consumers for duplicated interpretation or unclear ownership. Complete any directly related refactor in the same change and remove superseded code. Passing regression tests alone does not establish that the brittleness is resolved. If related cleanup remains, explain what remains and why. +When addressing a review thread, inspect the producing boundary and affected consumers for brittleness. Complete directly related refactoring in the same change. Before resolving the thread, explain the root cause, structural fix, validation, and any remaining cleanup. + Parse unknown boundary values and model expected failures with the tagged error algebra and `Result`. Preserve partial facts and meaningful failure reasons. Keep identity and lookup values separate from display formatting; preserve source values and explicit unknowns when normalization loses information. Absolute paths, file URLs, and HTTP paths have different semantics. Validate advertised JSON Schemas after SDK conversion against their declared dialect and actual clients. Bind handlers to named contracts with exact types; catalog ordering must never select an operation or schema. From bb266226707135ad9f181e6c3afd6af0ef62fb22 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:59 +0800 Subject: [PATCH 34/93] fix(cli): retain correction details across adjacent browser commands (#2294) * fix(cli): retain correction details across browser commands Fold the complementary implementation from #2247 into the earlier #2230 proposal, retaining the original contribution history. Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> * fix(browser): retain diagnostics from the selected input branch * fix(cli): recognize incomplete browser scenarios by their owned fields --------- Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> --- src/cli/browserAdvancedCommands.ts | 58 ++++++--- src/domain/browserObservation.ts | 17 ++- tests/boundary/cli/browserCli.test.ts | 115 +++++++++++++++++- .../cli/browserInputDiagnostics.test.ts | 73 +++++++++++ tests/boundary/mcp/toolSchemaValidity.test.ts | 29 +++++ 5 files changed, 269 insertions(+), 23 deletions(-) diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index 1eff33f4bc..50c9ce4465 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -14,7 +14,9 @@ import { AnalysisInputError, type AnalysisInputIssue, } from "../domain/analysisErrorCore.js"; -import { browserCaptureComparisonInputSchema } from "../domain/browserCaptureComparison.js"; +import { compareBrowserScenariosInputSchema } from "../domain/browserScenarioDiffValues.js"; +import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js"; +import { compareWebCapturesInputSchema } from "../domain/webCaptureDiffSchemas.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { discoverWebMcpToolsInputSchema } from "../domain/webMcpDiscovery.js"; import { @@ -59,13 +61,18 @@ const registerWebMcp = ( run: ({ args, options }) => logCliCommand(logger, "discover-webmcp-tools", async () => { const context = browserContext(); - const parsed = discoverWebMcpToolsInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, target_id: args.targetId, observation_ms: options.observationMs, - }); - if (!parsed.success) return inputError("discover_webmcp_tools"); + }; + const parsed = discoverWebMcpToolsInputSchema.safeParse(input); + if (!parsed.success) + return inputError( + "discover_webmcp_tools", + projectInputIssues(parsed.error.issues, input), + ); const result = await discoverWebMcpTools(context.provider, parsed.data); return result.ok ? result.value : browserCliError(result.error); }), @@ -104,16 +111,28 @@ const registerCaptureDiff = ( }); if (!inputs.ok) return inputs.error; const { before, after, normalization } = inputs.value; - const scenarioComparison = - browserCaptureComparisonInputSchema.safeParse({ - before_scenario: before, - after_scenario: after, - normalization, - }); - const parsed = scenarioComparison.success - ? scenarioComparison - : browserCaptureComparisonInputSchema.safeParse({ before, after }); - if (!parsed.success) return inputError("compare_web_captures"); + // Passive snapshots own the inspection wrapper. Otherwise recognize + // scenario-owned fields even when the scenario manifest is missing. + const scenarioInput = [before, after].some( + (capture) => + typeof capture === "object" && + capture !== null && + !("inspection" in capture) && + Object.keys(browserScenarioCaptureSchema.shape).some( + (field) => field in capture, + ), + ); + const input = scenarioInput + ? { before_scenario: before, after_scenario: after, normalization } + : { before, after }; + const parsed = scenarioInput + ? compareBrowserScenariosInputSchema.safeParse(input) + : compareWebCapturesInputSchema.safeParse(input); + if (!parsed.success) + return inputError( + "compare_web_captures", + projectInputIssues(parsed.error.issues, input), + ); const result = await compareWebCaptureEvidence( new CdpBrowserProvider(), parsed.data, @@ -140,12 +159,17 @@ const registerScreenshot = ( run: ({ args, options }) => logCliCommand(logger, "capture-web-screenshot", async () => { const context = browserContext(); - const parsed = captureWebScreenshotInputSchema.safeParse({ + const input = { cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins, target_id: args.targetId, - }); - if (!parsed.success) return inputError("capture_web_screenshot"); + }; + const parsed = captureWebScreenshotInputSchema.safeParse(input); + if (!parsed.success) + return inputError( + "capture_web_screenshot", + projectInputIssues(parsed.error.issues, input), + ); const result = await captureWebScreenshot( context.provider, parsed.data, diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index d32a2adc1a..0a9fc8e32d 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -105,7 +105,7 @@ const inspectWebPageInputFacts = { const inspectWebPageWithoutSourceSchema = z.strictObject({ ...inspectWebPageInputFacts, - include_script_sources: z.literal(false).default(false), + include_script_sources: z.literal(false).optional().default(false), }); const inspectWebPageWithSourceShapeSchema = z.strictObject({ @@ -134,10 +134,17 @@ export const inspectWebPageWithSourceInputSchema = inspectWebPageWithSourceShapeSchema.superRefine(refineInspectWebPageInput); /** Caller-visible schema for one passive inspection. */ -export const inspectWebPageInputSchema = z.union([ - inspectWebPageWithoutSourceSchema.superRefine(refineInspectWebPageInput), - inspectWebPageWithSourceInputSchema, -]); +export const inspectWebPageInputSchema = z.discriminatedUnion( + "include_script_sources", + [ + inspectWebPageWithoutSourceSchema.superRefine(refineInspectWebPageInput), + inspectWebPageWithSourceShapeSchema + .extend({ + include_script_sources: z.literal(true), + }) + .superRefine(refineInspectWebPageInput), + ], +); export type ListBrowserTargetsInput = z.infer< typeof listBrowserTargetsInputSchema diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index 7b2cb93ba0..5f8193fce4 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -93,7 +93,7 @@ it( const compared = await runCli( [ "compare-web-captures", - JSON.stringify({ inspection }), + JSON.stringify({ inspection, scenario: { caller_metadata: true } }), JSON.stringify({ inspection }), "--json", ], @@ -183,6 +183,119 @@ it( INTEGRATION_TEST_TIMEOUT_MS, ); +describe("scenario comparison CLI diagnostics", () => { + const rule = { + rule_id: "domain", + artifacts: ["url"], + match: "app.example.test", + replacement: "normalized.test", + }; + it.each([ + { + name: "duplicate rule IDs", + normalization: { rules: [rule, rule] }, + issue: { + path: ["normalization", "rules", 1, "rule_id"], + reason: "invalid_value", + message: "Normalization rule IDs must be unique", + }, + }, + { + name: "duplicate artifact kinds", + normalization: { rules: [{ ...rule, artifacts: ["url", "url"] }] }, + issue: { + path: ["normalization", "rules", 0, "artifacts"], + reason: "invalid_value", + message: "Normalization rule artifact kinds must be unique", + }, + }, + { + name: "structurally invalid rules", + normalization: { rules: "invalid" }, + issue: { + path: ["normalization", "rules"], + reason: "invalid_type", + expected: "array", + }, + }, + ])( + "retains scenario normalization diagnostics for $name", + async ({ normalization, issue }) => { + const capture = JSON.stringify(completeScenarioCapture()); + const result = await runCli([ + "compare-web-captures", + capture, + capture, + "--normalization-json", + JSON.stringify(normalization), + "--json", + ]); + expect(result).toMatchObject({ + code: "invalid_request", + details: { operation: "compare_web_captures", issues: [issue] }, + }); + }, + ); + + it("keeps malformed scenario diagnostics in the selected capture group", async () => { + const capture = completeScenarioCapture(); + const result = await runCli([ + "compare-web-captures", + JSON.stringify({ ...capture, duration_ms: "invalid" }), + JSON.stringify(capture), + "--json", + ]); + expect(result).toMatchObject({ + code: "invalid_request", + details: { + operation: "compare_web_captures", + issues: [ + { + path: ["before_scenario", "duration_ms"], + reason: "invalid_type", + expected: "number", + }, + ], + }, + }); + }); + + it.each(["omitted", "misspelled"])( + "reports the missing scenario manifest when it is %s on both captures", + async (kind) => { + const { scenario, ...capture } = completeScenarioCapture(); + const input = JSON.stringify( + kind === "omitted" ? capture : { ...capture, scneario: scenario }, + ); + const result = await runCli([ + "compare-web-captures", + input, + input, + "--json", + ]); + expect(result).toMatchObject({ + code: "invalid_request", + details: { + operation: "compare_web_captures", + issues: expect.arrayContaining([ + { + path: ["before_scenario", "scenario"], + reason: "missing_argument", + expected: "object", + }, + { + path: ["after_scenario", "scenario"], + reason: "missing_argument", + expected: "object", + }, + ]), + }, + }); + expect(JSON.stringify(result)).not.toContain('"inspection"'); + }, + ); +}); + describe("browser CLI capture parity", () => { it( "runs analysis, session, WebMCP, screenshot, and comparison from explicit request scope", diff --git a/tests/boundary/cli/browserInputDiagnostics.test.ts b/tests/boundary/cli/browserInputDiagnostics.test.ts index e6b1646ae1..745948e925 100644 --- a/tests/boundary/cli/browserInputDiagnostics.test.ts +++ b/tests/boundary/cli/browserInputDiagnostics.test.ts @@ -61,8 +61,59 @@ const invalidInputs = [ error: "JavaScript runtime observation failed", issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, }, + { + command: "discover-webmcp-tools", + arguments: [malformedEndpoint, "target"], + operation: "discover_webmcp_tools", + error: "Browser observation failed", + issue: { + path: ["cdp_endpoint"], + reason: "invalid_value", + message: "Invalid CDP endpoint URL", + }, + }, + { + command: "capture-web-screenshot", + arguments: [browserEndpoint, ""], + operation: "capture_web_screenshot", + error: "Browser observation failed", + issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + }, ] as const; +describe("inspection branch diagnostics", () => { + cliTest.for([[], ["--include-script-sources"]])( + "reports only the selected inspection branch's endpoint error (%j)", + async (flags, { cli, workspace }) => { + const result = await cli.run({ + arguments: [ + "inspect-web-page", + malformedEndpoint, + "target", + ...flags, + "--json", + ], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_web_page", + issues: [ + { + path: ["cdp_endpoint"], + reason: "invalid_value", + message: "Invalid CDP endpoint URL", + }, + ], + }, + }); + }, + ); +}); + describe("browser and Inspector CLI input diagnostics", () => { cliTest.for(invalidInputs)( "$command retains the named contract's correction details", @@ -159,6 +210,28 @@ describe("browser and Inspector CLI input diagnostics", () => { }, ); + cliTest( + "retains correction details for invalid capture comparison inputs", + async ({ cli, workspace }) => { + const result = await cli.run({ + arguments: ["compare-web-captures", "{}", "{}", "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "compare_web_captures", + issues: expect.arrayContaining([ + expect.objectContaining({ reason: expect.any(String) }), + ]), + }, + }); + }, + ); + cliTest( "preserves the parser diagnostic for an omitted endpoint", async ({ cli, workspace }) => { diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index b833310c3f..5a94d27bfd 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -454,6 +454,35 @@ describe("MCP root input schemas", () => { expectStrictInputSchemaParity(tools, ajv); + for (const name of ["inspect_web_page", "analyze_web_bundle"]) { + const contract = TOOL_CONTRACTS.find((tool) => tool.name === name); + const tool = advertised.get(name); + if (contract === undefined || tool === undefined) + throw new Error(`${name} was not advertised`); + const validate = ajv.compile(tool.inputSchema); + const base = { + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }; + for (const flag of [undefined, false, true, "false", null]) { + const input = + flag === undefined + ? base + : { ...base, include_script_sources: flag }; + const accepted = + flag === undefined || + flag === true || + (name === "inspect_web_page" && flag === false); + const parsed = contract.inputSchema.safeParse(input); + expect(parsed.success, `${name}: ${String(flag)}`).toBe(accepted); + expect(validate(input), `${name}: ${String(flag)}`).toBe(accepted); + if (parsed.success && flag === undefined) + expect(parsed.data).toMatchObject({ + include_script_sources: name === "analyze_web_bundle", + }); + } + } + const nativeObservation = TOOL_CONTRACTS.find( ({ name }) => name === "observe_native_ui", ); From f76163c2467db623f9de0bda757c9d8610ca9565 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:09:32 +0800 Subject: [PATCH 35/93] feat(evm): add retained interface summary delivery (#2308) Add opt-in EVM MCP summary delivery and retained/portable function and text views. Keep existing complete defaults, provider arguments and original full Evidence identities and records usable. Require retention before selected analysis; preserve inferred candidates, nulls, diagnostics, provenance and explicit unknowns. Verify old schemas, historical Evidence migration, 64 focused feature checks, 30 affected MCP checks after latest-main rebase, 59 real EVM CLI/MCP cases and six shared-domain artifact workflows. Repeated SDK delivery is smaller; all real model trials and mixed token/time outcomes, limitation failures and resource qualification gaps remain documented without a uniform performance claim. Refs #2016 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 6 +- docs/evm-bytecode.md | 45 +++- experiments/analysis-efficiency/README.md | 4 + experiments/analysis-efficiency/compare.mjs | 1 + .../evm-interface-summary.md | 208 +++++++++++++++ experiments/analysis-efficiency/measure.mjs | 103 +++++++- .../analysis-efficiency/review-model.py | 5 + .../verify-evm-retention.mjs | 167 ++++++++++++ .../analysisView/AnalysisViewService.ts | 53 ++-- src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/evm/evmToolContracts.ts | 30 ++- src/domain/analysisView/analysisView.ts | 30 ++- src/domain/analysisView/binaryLayoutView.ts | 4 + src/domain/analysisView/evmInterfaceView.ts | 145 +++++++++++ .../analysisView/javascriptApplicationView.ts | 4 + src/server/createServer.ts | 1 + src/server/registerEvmTools.ts | 31 ++- tests/boundary/mcp/evmInterfaceMcp.test.ts | 244 +++++++++++++++++- 18 files changed, 1049 insertions(+), 34 deletions(-) create mode 100644 experiments/analysis-efficiency/evm-interface-summary.md create mode 100644 experiments/analysis-efficiency/verify-evm-retention.mjs create mode 100644 src/domain/analysisView/evmInterfaceView.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index fc6237dcd1..87decc26d5 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -75,7 +75,11 @@ explicit path or endpoint and do not need it. - Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors, argument strings and mutability as inferred candidates; this performs no chain - lookup or target execution. See the + lookup or target execution. For the complete inferred interface without hex or + duplicate stdout, discover support and select `detail: "summary"`; complete + Evidence stays retained under its original identity. Use the returned + `parent_evidence_id` for exact function or `bytecode_hex`/stdout/stderr pages. + Candidate coverage does not establish interface completeness. See the [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md). - Embedded Go compiler, module or build-setting metadata: `inspect_go_binary` with the explicit local file. This built-in target-free inspection requires no diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 8db25973ed..972d8b92cc 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -35,7 +35,7 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes. Neither digest is the Ethereum Keccak code hash. - Full bytecode, upstream function offsets and original producer representation - are returned inline. Offsets are byte positions in decoded bytecode. + are returned inline by default. Offsets are byte positions in decoded bytecode. - Upstream also returns terminal metadata during selector recovery. Its reported values remain in `raw_result`; integer precision and non-text-key coverage are unknown in the JS binding. Full bytecode retains the original encoded metadata. @@ -47,6 +47,49 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) - Analysis is offline: no target runtime execution, implicit chain/RPC/explorer lookup, wallet action, transaction broadcast or network request. +## Selected interface delivery and retained views + +Existing CLI calls and MCP calls with omitted or `complete` detail keep the +complete inline result. On a retaining MCP server, select `detail: "summary"` +when the question needs every inferred function and its identity/uncertainty, +without bytecode text or duplicated raw stdout: + +```json +{ + "name": "inspect_evm_interface", + "arguments": { + "path": "/artifacts/runtime.hex", + "encoding": "hex", + "detail": "summary" + } +} +``` + +The result is distinct derived Evidence linked to its complete parent. Its +`normalized_result.summary` preserves every producer-returned selector, decoded +byte offset, dispatch, argument/mutability string including null, carrier/decoded +identities, original limitations and explicit unknowns. Payload `evidence_kind` +remains `inferred`. It preserves stderr/truncation and records stdout text length. +Complete-within-view coverage covers returned candidates; discovery completeness +remains unknown even for an empty or exhausted collection. Analysis still runs; +delivery selection does not save interpreter work or change source parameters. + +Use `normalized_result.parent_evidence_id` with `inspect_analysis_view` source +kind `retained-evidence`. EVM view kind `evm` pages `functions` by original row +order, or `bytecode_hex`, `stdout`, `stderr` by UTF-16 code units. Hex pages preserve +the original lowercase text without a prefix; two characters represent one decoded +byte. A text-page offset differs from a function's decoded-byte offset. Complete +parent Evidence remains accessible through the normal ledger/export workflow; +reading views does not rerun analysis or change its identity. Existing record +retention and close semantics are unchanged. + +Portable historical complete EVM Evidence can use source kind `inline` without +a provider. A fixed server without Evidence retention rejects selected summary +delivery before starting analysis and continues to support complete delivery. +Discover the actual advertised schema before sending `detail` to an older server; +use its complete operation when an equivalent workflow is required. The one-shot +CLI keeps its existing complete result. + ## Resource and lifecycle limits Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 25438895da..852e28ccd8 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -15,6 +15,10 @@ Follow-up: [EVM descriptor and real-template controls](evm-descriptor.md) extend the offline corpus with digest-pinned Uniswap runtime templates and an independent published ABI selector oracle, preserving unknown deployment/immutable metadata. +Follow-up: [EVM interface summary delivery](evm-interface-summary.md) records +the opt-in retained view, old-client/Evidence migration checks and repeated SDK +and real-model pairs, including mixed token/time results and strict-answer limits. + The 12 detailed checkpoint receipts were archived byte-for-byte before removal from the final PR tree. The archive `review-receipts-00d51e0fc-20261012` is in the private experiment lab outside the checkout; its `archive-index.json` records diff --git a/experiments/analysis-efficiency/compare.mjs b/experiments/analysis-efficiency/compare.mjs index b6ba1a5528..6db538311e 100644 --- a/experiments/analysis-efficiency/compare.mjs +++ b/experiments/analysis-efficiency/compare.mjs @@ -24,6 +24,7 @@ const sameSettings = [ "artifact", "artifact_sha256", "artifact_sha256_after", + "retained_verification", "native_question", "javascript_question", "provider_settings_sha256", diff --git a/experiments/analysis-efficiency/evm-interface-summary.md b/experiments/analysis-efficiency/evm-interface-summary.md new file mode 100644 index 0000000000..cbc793a5b0 --- /dev/null +++ b/experiments/analysis-efficiency/evm-interface-summary.md @@ -0,0 +1,208 @@ +# EVM interface delivery and migration checkpoint + +Continues [#2016](https://github.com/morluto/rea/issues/2016) after the static +descriptor merged in [#2261](https://github.com/morluto/rea/pull/2261). Opt-in +`inspect_evm_interface` MCP `detail: "summary"` returns every inferred interface +candidate without bytecode hex or duplicate stdout. Complete Evidence remains +retained under its original identity. Delivery bytes fall; the measured model +cohort does **not** establish a stable token or task-time improvement. + +## Frozen scope + +Control production is `ad08510a113d7a9b8d92fb1e385757880e469869`; candidate +production is `32c7548b9ae5c8cda348653d177b1747127c74ba`. Their compiled file-table +SHA-256 values are respectively +`f68e85030cb05082520ea9f0d7650298ae316ce7bba59a8942e0cc1318ae9850` and +`978b3a014e3b2a1e1c6620364dcd793fa7cb40f19892d44cfb0312739a8c14a3`. +The SDK driver SHA-256 is +`a95d8d8e9f72c0b2b885c61907ed13054ebf7d1642f6f13b0126163840b1221a`. +Driver commit `03bff83e70e09a917341c1fe3ebffbfdc67785c5` fixes only reading the +public `get_evidence_bundle.result.records` envelope; production is unchanged. +Both runtimes use Node 24.18.0, matching locked dependencies, SDK client/server +2.3.1 and unchanged bundled EVMole 0.9.3. + +Inputs are the digest-pinned WETH historical hex carrier and Uniswap v3-core +1.0.1 Factory/Pool compiler runtime templates in [corpus.json](corpus.json). +Original carrier and decoded-byte identities remain distinct. Templates are +not authenticated deployed instances; constructor-dependent values and absent +immutable-reference metadata stay unknown. No chain, target execution or +signature lookup is introduced. + +## Public SDK pairs + +Two pairs per artifact run serially: complete control then selected candidate, +followed by candidate then control. Each starts a fresh MCP process, discovers +the catalog and validates the actual named advertised schema. Host caches are +not independently controlled. All six fact/settings comparisons pass. Both +answer projections omit unrequested hex; original complete replies remain intact. + +| Input | Complete / summary result bytes | Reduction | Control seconds, 1 / 2 | Candidate seconds, 1 / 2 | +| ------- | ------------------------------: | --------: | ---------------------: | -----------------------: | +| Factory | 110,424 / 14,442 | 86.92% | 3.670 / 3.766 | 3.664 / 3.867 | +| Pool | 110,998 / 19,922 | 82.05% | 4.110 / 3.740 | 3.977 / 3.870 | +| WETH | 26,760 / 15,378 | 42.53% | 3.671 / 3.710 | 3.930 / 3.673 | + +These measurements support smaller delivery, not faster interpretation. Analysis +still runs and the full parent still incurs producer/retention costs. Catalog +count and order stay at 184 tools. The complete catalog grows from 2,422,229 to +2,429,938 compact JSON bytes (+7,709); all name/description/input definitions +grow from 512,655 to 514,074 bytes. The EVM input definition grows from 1,443 to +2,029 bytes. Only `inspect_analysis_view` and `inspect_evm_interface` definitions +change. These discovery costs are visible and are not converted into token counts. + +Nine separate measurements verify new-server complete defaults, old-server +summary fallback and retained reads for all three inputs. The four-call retained +proofs retrieve the complete canonical parent plus pages and cost 160,110, +177,106 and 79,160 bytes for Factory, Pool and WETH respectively. They are +verification workflows, not examples of one-call delivery savings. + +## Real Codex pairs and answer qualification + +Twelve serial Codex CLI 0.161.0 trials use the existing identical questions for +each pair, the same artifact path, one enabled EVM tool, temporary MCP profiles +and requested `gpt-5.6-terra` high. The requested alias is not proof of the +resolved model identity. Trials include schema discovery, reasoning, one actual +successful EVM call and final answer. No call fails or retries. Profiles are +removed and input bytes remain unchanged. This fixed one-tool profile does not +test selection from the full catalog. + +| Input / repetition | Uncached input, control → candidate | Seconds, control → candidate | Candidate delivery | +| ------------------ | ----------------------------------: | ---------------------------: | ------------------ | +| Factory / 1 | 22,901 → 25,668 | 52.485 → 48.768 | summary | +| Factory / 2 | 37,181 → 15,270 | 53.404 → 45.345 | summary | +| Pool / 1 | 36,652 → 26,657 | 57.764 → 66.642 | summary | +| Pool / 2 | 28,305 → 36,259 | 62.068 → 71.756 | complete | +| WETH / 1 | 21,128 → 25,187 | 44.869 → 46.809 | summary | +| WETH / 2 | 17,417 → 17,529 | 30.364 → 39.943 | summary | + +Keep all trials, including the candidate that explicitly chose complete delivery. +Five of six candidates select summary, but uncached input improves in only two +pairs and time improves in only two. Aggregate control → candidate observations +are 362,496 → 357,770 total input; 198,912 → 211,200 cached input; +163,584 → 146,570 uncached input; 8,953 → 10,397 output; and +300.954 → 319.263 seconds. Cache state and model/service variation are not +separated; the aggregate does not establish a repeatable saving for an individual +question or a qualified performance recommendation. + +Every answer passes exact core-field checks: both identities/byte counts, every +function selector/offset/dispatch/argument/mutability including null, inference, +unknown completeness/kind/hardfork/authenticity, and absent runtime execution. +All reported IDs occur in actual responses. However, the existing grader requires +the final limitation list to equal the producer list verbatim and in order. +Only three trials pass that additional check; nine fail. The questions request +preserving limitations but do not explicitly request a verbatim ordered list. +Four failures contain all original strings with extra correct scope statements +or different ordering. The other five paraphrase or omit source caveats, including +worker or upstream-metadata limitations. Do not silently waive those omissions +or turn these into twelve fully acceptable answers. The strict reports and +original transcripts remain intact; core-field success is a narrower result. + +Model scope sampled peaks range from 562.5 to 710.5 MiB, with last-observed CPU +5.945–6.878 seconds. CPU includes the local process family, not remote model +computation. All heavy jobs use one CPU, a 4 GiB hard limit, 3 GiB early-stop, +no swap and 192 tasks. No scope abort occurs in this cohort. Final cgroup +counters are unavailable after teardown: final OOM/task/limit-event assertions +remain **unverified**. Last-observed counters are not substituted for final ones. +Consequently neither strict answer acceptance nor resource-qualified performance +success is claimed for the whole cohort. + +## Existing-user compatibility + +Omitted and explicit-complete MCP requests, the existing CLI and provider inputs +preserve their full behavior. `detail` is removed before the unchanged provider +input; original full Evidence parameters, raw result and identity are retained. +Summary receives its own identity linked to that parent. All candidate facts, +original report limitations, stderr/truncation and explicit unknowns stay inline. +Complete-within-view candidate coverage is not global discovery completeness. +Function pages retain original order; text pages use UTF-16 code units, with +hex characters distinguished from decoded-byte function offsets. + +Actual new default replies for all three artifacts validate against the frozen +old advertised input/output schemas, and structured/text payloads agree. Older +servers reject neither request nor unsupported fields because the experimental +client discovers support before selecting summary and otherwise calls complete. +This is an explicit client fallback, not universal server forward compatibility. + +Three authentic old-runtime records and old-created canonical bundles pass new +MCP import, retained summary, CLI inline summary and export. Original records, +IDs and bundle source bytes stay deeply equal. The reproducible verifier below +also checks function/hex pages with the analysis limiter explicitly unavailable, +so retained reads cannot silently start successful producer analysis. This +proves the pinned old/new Linux workflows, not every historical client release. + +The initial retained experiment failed because its driver read the bundle's +wrong envelope. Correcting that consumer produced passing retained proofs; the +failed receipt stays archived and is not recorded as a REA defect. Separately, +one static-check attempt hit the unchanged 3 GiB early-stop. The bounded retry +passes with Node heap 512 MiB/native checker memory controls; its roughly 3 GiB +peak and soft-limit events are excluded from efficiency comparisons. + +## Reproduction and checks + +Use [the shared instructions](README.md), explicit frozen revisions/runtimes and +fresh private output directories. Run `measure.mjs` for `weth.hex`, +`uniswap-v3.factory.hex` and `uniswap-v3.pool.hex`, with `--schemas selected` and +paired `--delivery complete` / `summary`; run `compare.mjs` on equivalent pairs. +Add `--verify-retained` only for the separate four-call proof. Use the unchanged +[WETH](questions/evm.txt) or [template](questions/evm-uniswap.txt) question and the +same one-tool profile for `agent-trial.py`, preserving all usage and grader results. + +Export complete Evidence with the old runtime's public ledger workflow, then run: + +```sh +node experiments/analysis-efficiency/verify-evm-retention.mjs \ + --runtime-root ABS_FROZEN_NEW_RUNTIME --revision NEW_SHA \ + --bundle ABS_OLD_BUNDLE_JSON --output NEW_PRIVATE_ABS_DIR +``` + +The script acquires no dependencies and preserves the old bundle. Revision is +caller-supplied; retain the frozen file-table receipt alongside it. Raw JSON, +transcripts, profiles, bytecode, compiler inputs and generated artifacts stay +outside Git. This checkpoint adds source verification and curated observations. + +The production change passes 64 focused EVM/WASM/generic-view/domain/service +checks and full static/docs gates. Recording provider seams establish exact +schemas, old defaults, inference/null preservation, historical identity failures, +peer availability, cancellation and cleanup retry; they are not real-provider +claims. Real-provider and shared-domain checks are recorded separately. + +The same frozen candidate also passes six actual public MCP artifact workflows: +SQLite WASM with WABT summary, ShareX managed metadata, Obsidian inventory, +NewPipe with JADX, an OpenWrt region inspection, and jq entry analysis with +Ghidra. These are shared-contract regressions; they do not establish AST analysis +of all Obsidian code, full firmware reconstruction, browser coverage, or new token +savings in those domains. Native Windows PE and DOS coverage remains at the +[earlier native checkpoint](native-entry-views.md), not a new Ghidra/Windows claim. +The source retention verifier passes for all three authentic old EVM bundles, +including exact summaries, function/hex pages, CLI inline views and unchanged +exported originals with the limiter explicitly unavailable. + +The frozen candidate passes all 59 existing real EVM CLI/MCP verifier cases with +caller-supplied solc 0.8.30 and strace 6.1, including malformed/unsupported +carriers, unavailable limiter and capacity failures. Its scope takes 96.076 +seconds with 1,345,052,672 sampled peak bytes and 95.130 last-observed CPU seconds. +This is regression evidence, not an interpreter speedup comparison; final +resource counters remain unverified. Fresh fast static and generated-document +checks also pass after adding the migration verifier and this report. + +## Latest-main integration + +The branch rebases without conflict onto main +`0043d9b134c0a353e8bf85c0bde4492275f1f720`. Production integration commit +`3766749316c345ccb594b1b748747b1af9582b34` preserves the same EVM projection, +provider and delivery semantics; intervening main adds unrelated capabilities. +The above performance cohort stays pinned to its original frozen runtimes and +is not relabeled as a measurement of the larger integrated catalog. + +At `cda794fb9d4a5ca2bd661853a899d579fb8a47b2`, all 30 affected EVM/WASM/generic-view +MCP tests pass again, as do full `npm run check` and `npm run docs:check`. A fresh +real Factory SDK selected-summary/retained-parent/page workflow and the original +Pool bundle's SDK/CLI migration verifier also pass against the rebuilt integrated +runtime. The latter still has an explicitly unavailable analysis limiter. + +The initial integrated static-check scope stops at 3,221,446,656 sampled bytes, +just above the unchanged 3 GiB early-stop. Running the same typecheck through +its single Turbo task with less launcher overhead passes at 3,219,353,600 sampled +bytes; the subsequent full gate uses that valid cached typecheck and passes. +No memory threshold or check requirement is relaxed. Preserve both receipts; +these near-limit validation jobs are excluded from efficiency conclusions. diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs index e763d07171..2a7911b4f6 100644 --- a/experiments/analysis-efficiency/measure.mjs +++ b/experiments/analysis-efficiency/measure.mjs @@ -3,7 +3,7 @@ import { createReadStream } from "node:fs"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { parseArgs } from "node:util"; +import { isDeepStrictEqual, parseArgs } from "node:util"; import { NATIVE_ARTIFACTS, measureNativeEntry, @@ -23,6 +23,7 @@ const { values } = parseArgs({ providers: { type: "string" }, output: { type: "string" }, delivery: { type: "string", default: "complete" }, + "verify-retained": { type: "boolean", default: false }, "native-question": { type: "string", default: "entry" }, "javascript-question": { type: "string", default: "inventory" }, schemas: { type: "string", default: "default" }, @@ -41,6 +42,10 @@ if ( ) || !["inventory", "selected"].includes(values["javascript-question"]) || !["default", "selected"].includes(values.schemas) || + (values["verify-retained"] && + !["weth.hex", "uniswap-v3.factory.hex", "uniswap-v3.pool.hex"].includes( + values.artifact, + )) || (!NATIVE_ARTIFACTS.includes(values.artifact) && values["native-question"] !== "entry") || (values.artifact !== "jq" && @@ -350,15 +355,44 @@ const inspectWasmTarget = async () => { }; }; -/** Identical complete interface question for pinned offline EVM carriers. */ +/** Identical interface facts for complete delivery, selected summary or old-server fallback. */ const inspectEvmTarget = async () => { + const definition = catalogDefinitions.get("inspect_evm_interface"); + if (!definition) throw new Error("Missing advertised EVM definition"); + const input = { path: values.path, encoding: "hex" }; + const supportsSummary = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(definition.inputSchema)({ ...input, detail: "summary" }); const info = await call("inspect_evm_interface", { - path: values.path, - encoding: "hex", + ...input, + ...(values.delivery === "summary" && supportsSummary + ? { detail: "summary" } + : {}), }); - const n = normalized(info); + const result = normalized(info); + const projected = result.kind === "summary"; + const n = projected ? result.summary : result; + const references = [info.evidence_id]; + if (projected) { + if ( + result.parent_operation !== "inspect_evm_interface" || + result.parent_evidence_id === info.evidence_id || + !info.evidence_links.includes(result.parent_evidence_id) || + "hex" in n.bytecode || + "stdout" in n.diagnostics + ) + throw new Error( + "EVM summary changed source provenance or precise delivery", + ); + references.push(result.parent_evidence_id); + } if ( n.evidence_kind !== "inferred" || + n.artifact.path !== values.path || + n.artifact.sha256 !== inputSha || + n.artifact.bytes !== artifact.bytes || + n.artifact.encoding !== "hex" || n.discovery_completeness !== "unknown" || n.runtime_execution !== "not-performed" || ["kind", "hardfork", "deployment_authenticity"].some( @@ -370,15 +404,69 @@ const inspectEvmTarget = async () => { n.bytecode.bytes !== artifact.decoded_bytes) ) throw new Error("EVM interface identity or uncertainty changed"); + if (values["verify-retained"]) { + if (!projected) + throw new Error( + "Retained verification requires supported selected summary", + ); + const bundle = (await call("get_evidence_bundle", {})).result; + const parent = bundle.records.find( + (record) => record.evidence_id === result.parent_evidence_id, + ); + if ( + !parent || + parent.operation !== "inspect_evm_interface" || + parent.subject?.digest.sha256 !== inputSha || + parent.parameters.path !== values.path || + parent.parameters.encoding !== "hex" || + "detail" in parent.parameters + ) + throw new Error( + "Complete original EVM source was not retained unchanged", + ); + const original = normalized(parent); + const { hex: _hex, ...identity } = original.bytecode; + const { stdout, ...diagnostics } = original.diagnostics; + const expected = { + ...original, + bytecode: identity, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + }; + if (!isDeepStrictEqual(expected, n)) + throw new Error("EVM summary changed selected original facts"); + for (const [facet, item] of [ + ["functions", original.functions.slice(0, 64)], + [ + "bytecode_hex", + { text: original.bytecode.hex.slice(0, 64), unit: "utf16-code-units" }, + ], + ]) { + const page = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "evm", facet, offset: 0, limit: 64 }, + }); + const v = normalized(page); + if ( + !isDeepStrictEqual(v.item, item) || + v.parent_evidence_id !== parent.evidence_id || + v.artifact.sha256 !== inputSha + ) + throw new Error("EVM retained read changed source facts or identity"); + if (references.includes(page.evidence_id)) + throw new Error("EVM retained read reused an Evidence identity"); + references.push(page.evidence_id); + } + } + const { hex: _hex, ...bytecode } = n.bytecode; return { artifact_sha256: targetSha(info), artifact: n.artifact, - bytecode: n.bytecode, + bytecode, functions: n.functions, discovery_completeness: n.discovery_completeness, runtime_execution: n.runtime_execution, limitations: n.limitations, - evidence_ids: [info.evidence_id], + evidence_ids: references, }; }; @@ -672,6 +760,7 @@ const report = { artifact_sha256_after: finalInputSha, delivery: values.delivery, sdk_schema_mode: values.schemas, + retained_verification: values["verify-retained"], native_question: NATIVE_ARTIFACTS.includes(values.artifact) ? values["native-question"] : null, diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py index c31a012902..79e070181e 100644 --- a/experiments/analysis-efficiency/review-model.py +++ b/experiments/analysis-efficiency/review-model.py @@ -98,8 +98,13 @@ def main(): check('wabt_' + key, answer.get('wabt_' + key), reference['tool_profile'][key]) elif domain == 'evm': n = producer_results['inspect_evm_interface'][-1]['normalized_result'] + if n.get('kind') == 'summary': + check('evm_summary.parent_operation', n.get('parent_operation'), 'inspect_evm_interface') + checks['evm_summary.parent_reference_observed'] = n.get('parent_evidence_id') in observed_ids + n = n['summary'] for key, expected in {'artifact_bytes': n['artifact']['bytes'], 'bytecode_sha256': n['bytecode']['sha256'], 'bytecode_bytes': n['bytecode']['bytes'], 'functions': n['functions'], 'evidence_kind': n['evidence_kind'], 'discovery_completeness': n['discovery_completeness'], 'runtime_execution': n['runtime_execution'], 'bytecode_kind': n['bytecode']['kind'], 'hardfork': n['bytecode']['hardfork'], 'deployment_authenticity': n['bytecode']['deployment_authenticity']}.items(): check(key, answer.get(key), expected) + check('evm_original_limitations', answer.get('limitations'), n['limitations']) elif domain == 'android': for key in ['package_name', 'version_name', 'version_code', 'matches']: check(key, answer.get(key), reference[key]) diff --git a/experiments/analysis-efficiency/verify-evm-retention.mjs b/experiments/analysis-efficiency/verify-evm-retention.mjs new file mode 100644 index 0000000000..e5f7de74ca --- /dev/null +++ b/experiments/analysis-efficiency/verify-evm-retention.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import { parseArgs, promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +const { values } = parseArgs({ + options: { + "runtime-root": { type: "string" }, + revision: { type: "string" }, + bundle: { type: "string" }, + output: { type: "string" }, + }, +}); +for (const key of ["runtime-root", "bundle", "output"]) + assert.ok(values[key] && isAbsolute(values[key]), `Supply --${key} ABS`); +assert.ok(values.revision); +await mkdir(values.output, { mode: 0o700 }); +const sha = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const bytes = await readFile(values.bundle); +const original = JSON.parse(bytes.toString("utf8")); +const parents = original.records.filter( + (record) => record.operation === "inspect_evm_interface", +); +assert.ok(parents.length > 0, "Select a bundle containing old EVM Evidence"); +const entrypoint = resolve(values["runtime-root"], "scripts/rea.mjs"); +const environment = { + ...process.env, + REA_LOG_LEVEL: "silent", + NODE_OPTIONS: "--max-old-space-size=512", + // If a view accidentally starts analysis, its required limiter is unavailable. + REA_EVM_PRLIMIT_COMMAND: resolve(values.output, "absent-prlimit"), +}; +const client = new Client({ name: "rea-old-evm-retention", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const definitions = new Map(); +const calls = []; +const call = async (name, args) => { + const definition = definitions.get(name); + assert.ok(definition, `Missing named contract ${name}`); + const reply = await client.callTool( + { name, arguments: args }, + { toolDefinition: definition, timeout: 60_000 }, + ); + await writeFile( + resolve(values.output, `call-${calls.length + 1}.json`), + JSON.stringify(reply), + { flag: "wx", mode: 0o600 }, + ); + assert.notEqual(reply.isError, true, JSON.stringify(reply)); + const text = JSON.parse(reply.content.find((x) => x.type === "text").text); + assert.deepEqual(reply.structuredContent, text); + calls.push({ name, arguments: args, evidence_id: text.evidence_id ?? null }); + return text; +}; +const checked = []; +try { + await client.connect(transport); + transport.stderr?.resume(); + for (const definition of (await client.listTools()).tools) + definitions.set(definition.name, definition); + await call("import_evidence_bundle", { path: values.bundle }); + const imported = (await call("get_evidence_bundle", {})).result.records; + for (const parent of parents) { + assert.deepEqual( + imported.find((record) => record.evidence_id === parent.evidence_id), + parent, + ); + const source = { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }; + const view = await call("inspect_analysis_view", { + source, + view: { kind: "summary" }, + }); + const { hex, ...bytecode } = parent.normalized_result.bytecode; + const { stdout, ...diagnostics } = parent.normalized_result.diagnostics; + assert.deepEqual(view.normalized_result.summary, { + ...parent.normalized_result, + bytecode, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + }); + assert.equal(view.normalized_result.parent_evidence_id, parent.evidence_id); + assert.notEqual(view.evidence_id, parent.evidence_id); + assert.deepEqual(view.evidence_links, [parent.evidence_id]); + for (const [facet, expected] of [ + ["functions", parent.normalized_result.functions.slice(0, 2)], + ["bytecode_hex", { text: hex.slice(0, 64), unit: "utf16-code-units" }], + ]) { + const page = await call("inspect_analysis_view", { + source, + view: { + kind: "evm", + facet, + offset: 0, + limit: facet === "functions" ? 2 : 64, + }, + }); + assert.deepEqual(page.normalized_result.item, expected); + assert.equal( + page.normalized_result.parent_evidence_id, + parent.evidence_id, + ); + assert.deepEqual(page.evidence_links, [parent.evidence_id]); + } + const inlinePath = resolve(values.output, `inline-${checked.length}.json`); + await writeFile( + inlinePath, + JSON.stringify({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }), + { flag: "wx", mode: 0o600 }, + ); + const result = await promisify(execFile)( + process.execPath, + [entrypoint, "inspect-analysis-view", inlinePath, "--json"], + { env: environment, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }, + ); + const inline = JSON.parse(result.stdout); + assert.equal( + inline.normalized_result.parent_evidence_id, + parent.evidence_id, + ); + assert.deepEqual( + inline.normalized_result.summary, + view.normalized_result.summary, + ); + checked.push(parent.evidence_id); + } + const path = resolve(values.output, "exported.json"); + await call("export_evidence_bundle", { path }); + const exported = JSON.parse(await readFile(path, "utf8")); + for (const record of original.records) + assert.deepEqual( + exported.records.find((x) => x.evidence_id === record.evidence_id), + record, + ); + assert.deepEqual(await readFile(values.bundle), bytes); +} finally { + await client.close(); +} +const report = { + runtime_revision: values.revision, + original_bundle_sha256: sha(bytes), + checked_original_evidence_ids: checked, + original_records_and_source_bytes_unchanged: true, + exact_summaries_pages_and_cli_inline_pass: true, + calls, + scope: + "Old EVM Evidence through actual public SDK/CLI with an unavailable analysis limiter; original inferred facts, explicit unknowns, parent identities and export records preserved. Caller supplies the frozen runtime revision; this is migration verification, not a performance pair.", +}; +await writeFile( + resolve(values.output, "summary.json"), + JSON.stringify(report, null, 2), + { flag: "wx", mode: 0o600 }, +); +process.stdout.write(JSON.stringify(report) + "\n"); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 4af70eb4e2..a5b603a863 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -102,6 +102,38 @@ export interface AnalysisViewRetention { readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; } +/** Fail before analysis when selected delivery cannot keep its complete source. */ +export const requireAnalysisViewRetention = ( + operation: string, + retention: AnalysisViewRetention, +): Result< + { + readonly evidenceById: EvidenceLookup; + readonly recordEvidence: EvidenceWriter["recordEvidence"]; + }, + AnalysisError +> => { + if ( + retention.recordEvidence === undefined || + retention.evidenceById === undefined + ) + return err( + new AnalysisCapabilityUnavailableError( + ANALYSIS_VIEW_PROVIDER.id, + operation, + "summary detail requires a server session that retains Evidence for later views", + { + userMessage: + "This server does not retain Evidence. Select detail complete to receive the complete analysis inline.", + }, + ), + ); + return ok({ + evidenceById: retention.evidenceById, + recordEvidence: retention.recordEvidence, + }); +}; + /** * Retain complete analysis Evidence in the session, then project its summary * view from the retained record. Callers page or select the rest through @@ -111,29 +143,16 @@ export const summarizeRetainedAnalysis = ( evidence: Evidence, retention: AnalysisViewRetention, ): Result => { - if ( - retention.recordEvidence === undefined || - retention.evidenceById === undefined - ) - return err( - new AnalysisCapabilityUnavailableError( - ANALYSIS_VIEW_PROVIDER.id, - evidence.operation, - "summary detail requires a server session that retains Evidence for later views", - { - userMessage: - "This server does not retain Evidence. Select detail complete to receive the complete analysis inline.", - }, - ), - ); - const recorded = retention.recordEvidence(evidence); + const ready = requireAnalysisViewRetention(evidence.operation, retention); + if (!ready.ok) return ready; + const recorded = ready.value.recordEvidence(evidence); if (!recorded.ok) return recorded; return inspectAnalysisViewValidated( { source: { kind: "retained-evidence", evidence_id: evidence.evidence_id }, view: { kind: "summary" }, }, - retention.evidenceById, + ready.value.evidenceById, ); }; diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index f55aafcf7e..745009ebc1 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact or inspect_web_network_capture Evidence without repeating analysis. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory and WASM sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface or inspect_web_network_capture Evidence without repeating analysis. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM and EVM sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts index e8df79be32..197fee77bc 100644 --- a/src/contracts/evm/evmToolContracts.ts +++ b/src/contracts/evm/evmToolContracts.ts @@ -4,7 +4,18 @@ import { } from "../../domain/evm/evmInterface.js"; import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { + analysisDetailSchema, + analysisDetailOutputSchemaOf, +} from "../analysisDetail.js"; +import { evmInterfaceSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; + +/** Delivery selection leaves the existing provider input and original Evidence unchanged. */ +const evmInterfaceInspectionInputSchema = inspectEvmInterfaceInputSchema.extend( + { + detail: analysisDetailSchema.unwrap().optional(), + }, +); /** Target-free EVM capabilities are independent of archive/Apple artifact providers. */ export const INSPECT_EVM_INTERFACE_CONTRACT = { @@ -12,14 +23,25 @@ export const INSPECT_EVM_INTERFACE_CONTRACT = { ...toolContractMetadata("inspect_evm_interface"), kind: "artifact-provider", description: - "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.", - inputSchema: inspectEvmInterfaceInputSchema, - outputSchema: evidenceResultOf(evmInterfaceSchema), + 'Inspect explicit local flat EVM bytecode without target execution or chain/RPC access. Select raw or hex encoding explicitly. Omitted or complete detail returns original carrier and decoded identities, full bytecode and all inferred interface candidates inline. For all inferred selectors/offsets/arguments/mutability and identities without bytecode hex or duplicated stdout, select detail: "summary" on this first call: it retains unchanged complete Evidence and returns a distinct linked inspect_analysis_view summary. Read original function rows or bytecode_hex/stdout/stderr text pages using view kind evm and normalized_result.parent_evidence_id; original complete Evidence remains accessible. Bytecode kind, hardfork, deployed authenticity and discovery completeness stay unknown; summary coverage covers only producer-returned candidates. Unchanged bundled EVMole 0.9.3/WASM in an owned bounded worker is initially real-verified on Linux x64; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.', + inputSchema: evmInterfaceInspectionInputSchema, + outputSchema: analysisDetailOutputSchemaOf( + evmInterfaceSchema, + evmInterfaceSummaryViewResultSchema, + ), examples: [ { title: "Inspect a local runtime bytecode hex carrier", input: { path: "/artifacts/runtime.hex", encoding: "hex" }, }, + { + title: "Read every inferred interface candidate without bytecode text", + input: { + path: "/artifacts/runtime.hex", + encoding: "hex", + detail: "summary", + }, + }, ], } as const satisfies ToolContract; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 5395578770..2d5751c27c 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -35,6 +35,12 @@ import { apiSurfaceViewSchema, } from "./webNetworkApiSurfaceView.js"; import { projectNativeFunctionView } from "./nativeFunctionView.js"; +import { + projectEvmInterfaceView, + evmViewRequestSchema, + evmViewItemSchema, + evmSummarySchema, +} from "./evmInterfaceView.js"; import { projectArtifactInventoryView } from "./artifactInventoryView.js"; import { projectWasmArtifactView, @@ -154,6 +160,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeViewSchema, inventoryViewSchema, wasmViewRequestSchema, + evmViewRequestSchema, apiSurfaceViewSchema, ]); @@ -266,6 +273,7 @@ const viewResultBase = { "inspect_artifact", "inventory_artifact", "inspect_wasm_artifact", + "inspect_evm_interface", ]), parent_digest: digestSchema, view_digest: digestSchema, @@ -309,6 +317,13 @@ export const wasmArtifactSummaryViewResultSchema = z.strictObject({ summary: wasmSummarySchema, }); +/** Exact inferred-interface summary for EVM's caller-selected delivery. */ +export const evmInterfaceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_evm_interface"), + summary: evmSummarySchema, +}); + /** Projected facts for one selected view of retained analysis Evidence. */ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ z.strictObject({ @@ -329,6 +344,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ javascriptSummarySchema, inventorySummarySchema, wasmSummarySchema, + evmSummarySchema, ]), }), z.strictObject({ @@ -362,6 +378,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ item: wasmViewItemSchema, ...viewResultBase, }), + z.strictObject({ + kind: z.literal("evm"), + view: evmViewRequestSchema, + item: evmViewItemSchema, + ...viewResultBase, + parent_operation: z.literal("inspect_evm_interface"), + }), z.strictObject({ kind: z.literal("inventory"), view: inventoryViewSchema, @@ -427,7 +450,8 @@ export type UnsignedAnalysisView = Extract, "view_digest" > - | Omit, "view_digest">; + | Omit, "view_digest"> + | Omit, "view_digest">; /** Attach the content digest that must change when projected bytes change. */ export const sealAnalysisView = ( @@ -571,6 +595,10 @@ export const projectAnalysisView = ( const projected = projectWasmArtifactView(parent, view); return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; } + if (parent.operation === "inspect_evm_interface") { + const projected = projectEvmInterfaceView(parent, view); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } if ( parent.operation === "inspect_artifact" || parent.operation === "inventory_artifact" diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 2fa2551bb0..bb88b421da 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -164,6 +164,10 @@ export const projectBinaryLayoutView = ( return err( layoutIncompatible("WASM views require inspect_wasm_artifact Evidence."), ); + if (view.kind === "evm") + return err( + layoutIncompatible("EVM views require inspect_evm_interface Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/domain/analysisView/evmInterfaceView.ts b/src/domain/analysisView/evmInterfaceView.ts new file mode 100644 index 0000000000..a7b3432915 --- /dev/null +++ b/src/domain/analysisView/evmInterfaceView.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { uniqueSorted } from "../canonicalOrdering.js"; +import { evmInterfaceSchema } from "../evm/evmInterface.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; +import { err, ok, type Result } from "../result.js"; +import { + analysisViewInputError, + type AnalysisViewParent, + type AnalysisViewRequest, + type UnsignedAnalysisView, +} from "./analysisView.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisTextPage } from "./analysisViewText.js"; + +/** Retained inferred candidates or exact text; offsets for text count UTF-16 code units. */ +export const evmViewRequestSchema = z.strictObject({ + kind: z.literal("evm"), + facet: z.enum(["functions", "bytecode_hex", "stdout", "stderr"]), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +export const evmViewItemSchema = z.union([ + evmInterfaceSchema.shape.functions, + z.strictObject({ text: z.string(), unit: z.literal("utf16-code-units") }), +]); + +/** Complete inferred interface facts without the unrequested hex or duplicated stdout. */ +export const evmSummarySchema = z.strictObject({ + artifact: evmInterfaceSchema.shape.artifact, + bytecode: evmInterfaceSchema.shape.bytecode.omit({ hex: true }), + evidence_kind: evmInterfaceSchema.shape.evidence_kind, + functions: evmInterfaceSchema.shape.functions, + discovery_completeness: evmInterfaceSchema.shape.discovery_completeness, + runtime_execution: evmInterfaceSchema.shape.runtime_execution, + diagnostics: evmInterfaceSchema.shape.diagnostics + .omit({ stdout: true }) + .extend({ + stdout_code_units: z.number().int().nonnegative(), + }), + limitations: evmInterfaceSchema.shape.limitations, +}); + +/** Select already authenticated producer facts; no interpreter or provider is invoked. */ +export const projectEvmInterfaceView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "evm") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: "EVM Evidence supports summary and evm views.", + }, + ]), + ); + const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + "inspect_analysis_view", + parsed.error.issues.map((issue) => ({ + ...issue, + path: ["source", "normalized_result", ...issue.path], + })), + { source: { normalized_result: parent.normalizedResult } }, + { cause: parsed.error }, + ), + ); + const report = parsed.data; + if ( + parent.subject == null || + parent.subject.local_path !== report.artifact.path || + parent.subject.digest.sha256 !== report.artifact.sha256 + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: + "EVM parent subject must identify the report's exact carrier path and SHA-256.", + }, + ]), + ); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_evm_interface" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + limitations: uniqueSorted([...parent.limitations, ...report.limitations]), + unknowns: [ + "Candidates remain inferred; discovery completeness, bytecode kind, hardfork and deployment authenticity are unknown. Runtime execution was not performed.", + ], + }; + if (view.kind === "summary") { + const { hex: _hex, ...bytecode } = report.bytecode; + const { stdout, ...diagnostics } = report.diagnostics; + return ok({ + kind: "summary", + view, + ...shared, + summary: { + artifact: report.artifact, + bytecode, + evidence_kind: report.evidence_kind, + functions: report.functions, + discovery_completeness: report.discovery_completeness, + runtime_execution: report.runtime_execution, + diagnostics: { ...diagnostics, stdout_code_units: stdout.length }, + limitations: report.limitations, + }, + coverage: completeWithinViewCoverage( + report.functions.length, + report.functions.length, + ), + }); + } + if (view.facet === "functions") { + const item = report.functions.slice(view.offset, view.offset + view.limit); + return ok({ + kind: "evm", + view, + item, + ...shared, + coverage: pageViewCoverage( + view.offset, + item.length, + report.functions.length, + ), + }); + } + const text = + view.facet === "bytecode_hex" + ? report.bytecode.hex + : report.diagnostics[view.facet]; + const page = analysisTextPage(text, view.offset, view.limit, view.facet); + return page.ok ? ok({ kind: "evm", view, ...page.value, ...shared }) : page; +}; diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 140a30f9e9..5e5f9bb88d 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -253,6 +253,10 @@ export const projectJavaScriptApplicationView = ( return err( jsIncompatible("WASM views require inspect_wasm_artifact Evidence."), ); + if (view.kind === "evm") + return err( + jsIncompatible("EVM views require inspect_evm_interface Evidence."), + ); if (view.kind === "summary") return ok({ kind: "summary", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 19d169bd10..b67a765cac 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -527,6 +527,7 @@ const registerConfiguredAnalysisTools = ( toolLogger, recordEvidence, withAdmittedAnalysis, + evidenceById, ); registerSqliteTools( server, diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts index ff460672e9..ce155a9e5e 100644 --- a/src/server/registerEvmTools.ts +++ b/src/server/registerEvmTools.ts @@ -2,6 +2,13 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { err } from "../domain/result.js"; import { evmInterfaceModule } from "../composition/evm.js"; import type { Logger } from "pino"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; @@ -15,11 +22,33 @@ export const registerEvmTools = ( logger: Logger, recordEvidence?: EvidenceWriter["recordEvidence"], withAdmittedAnalysis?: WithAdmittedAnalysis, + evidenceById?: EvidenceLookup, ): void => { const contract = evmInterfaceModule.contract; const handler = createEvidenceToolHandler( server, - (input, options) => service.inspect(input, options), + async (rawInput, options) => { + const parsed = contract.inputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + contract.name, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + const { detail, ...input } = parsed.data; + const retention = { recordEvidence, evidenceById }; + if (detail === "summary") { + const ready = requireAnalysisViewRetention(contract.name, retention); + if (!ready.ok) return ready; + } + const inspected = await service.inspect(input, options); + return detail === "summary" && inspected.ok + ? summarizeRetainedAnalysis(inspected.value, retention) + : inspected; + }, logger, recordEvidence, withAdmittedAnalysis, diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index a39fd53a8d..704af92c56 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -8,7 +8,8 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, } from "../../../src/domain/analysisErrorCore.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { evmInterfaceSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { err, ok } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -249,3 +250,244 @@ it("settles the admitted EVM operation after SDK cancellation and accepts a subs expect((await harness.client.callTool(input)).isError).not.toBe(true); expect(invocations).toBe(2); }); + +it("returns the exact inferred interface summary while preserving complete defaults and retained source", async () => { + const value = evmInterfaceFixture(); + value.functions = [ + { + selector: "0x11223344", + bytecode_offset: 0, + dispatch: "abi", + inferred_arguments: null, + inferred_state_mutability: null, + }, + { + selector: "0xaabbccdd", + bytecode_offset: 4, + dispatch: "fallback", + inferred_arguments: "uint256", + inferred_state_mutability: "view", + }, + ]; + value.diagnostics = { + stdout: "exact producer JSON\n", + stderr: "source warning\n", + truncated: false, + }; + const inspect = vi.fn(() => + Promise.resolve(ok(evmInterfaceExecution(value))), + ); + const harness = await createApplicationMcpHarness({ + serverOptions: { evmInterface: new EvmInterfaceService({ inspect }) }, + }); + onTestFinished(harness.close); + const definition = (await harness.client.listTools()).tools.find( + (tool) => tool.name === "inspect_evm_interface", + ); + if (definition?.outputSchema === undefined) + throw new Error("Missing EVM output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = definition.inputSchema; + const outputSchema: Record = definition.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const input = { path: value.artifact.path, encoding: "hex" }; + let original; + for (const detail of [undefined, "complete"] as const) { + const response = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { ...input, ...(detail ? { detail } : {}) }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toEqual(value); + expect(evidence.parameters).toEqual(input); + original = evidence; + } + if (original === undefined) throw new Error("No complete control"); + const response = await harness.client.callTool({ + name: "inspect_evm_interface", + arguments: { ...input, detail: "summary" }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const derived = parseEvidence(response.structuredContent); + const view = evmInterfaceSummaryViewResultSchema.parse( + derived.normalized_result, + ); + expect(derived.evidence_id).not.toBe(original.evidence_id); + expect(derived.evidence_links).toEqual([original.evidence_id]); + expect(derived.confidence).toBe("derived"); + const { hex: _hex, ...bytecode } = value.bytecode; + expect(view).toMatchObject({ + parent_operation: "inspect_evm_interface", + parent_evidence_id: original.evidence_id, + summary: { + artifact: value.artifact, + bytecode, + functions: value.functions, + evidence_kind: "inferred", + discovery_completeness: "unknown", + runtime_execution: "not-performed", + limitations: value.limitations, + diagnostics: { + stderr: value.diagnostics.stderr, + stdout_code_units: value.diagnostics.stdout.length, + }, + }, + coverage: { examined: 2, total: 2, exhausted: true }, + }); + expect(view.summary.bytecode).not.toHaveProperty("hex"); + expect(view.summary.diagnostics).not.toHaveProperty("stdout"); + expect(harness.session.evidenceById(original.evidence_id)).toEqual(original); + for (const [facet, offset, limit, item] of [ + ["functions", 1, 1, [value.functions[1]]], + [ + "bytecode_hex", + 2, + 4, + { text: value.bytecode.hex.slice(2, 6), unit: "utf16-code-units" }, + ], + [ + "stdout", + 0, + 128, + { text: value.diagnostics.stdout, unit: "utf16-code-units" }, + ], + ] as const) { + const selected = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: original.evidence_id, + }, + view: { kind: "evm", facet, offset, limit }, + }, + }); + expect(selected.isError).not.toBe(true); + const parsed = toolContract("inspect_analysis_view").outputSchema.parse( + selected.structuredContent, + ); + expect(parsed.normalized_result).toMatchObject({ + parent_evidence_id: original.evidence_id, + item, + }); + } + expect(inspect).toHaveBeenCalledTimes(3); + for (const call of inspect.mock.calls) + expect(call).toEqual([input, expect.any(Object)]); + expect(harness.session.evidenceById(original.evidence_id)).toEqual(original); +}); + +it.each(["valid", "wrong-subject", "no-subject"])( + "preserves historical portable EVM Evidence and rejects incompatible identity: %s", + async (scenario) => { + const value = evmInterfaceFixture(); + const source = createEvidence( + scenario === "no-subject" + ? undefined + : { + path: value.artifact.path, + sha256: + scenario === "wrong-subject" + ? "b".repeat(64) + : value.artifact.sha256, + format: "file", + }, + { id: "historical-evm", name: "Original EVM producer", version: "1" }, + { + operation: "inspect_evm_interface", + parameters: { path: value.artifact.path, encoding: "hex" }, + result: value, + confidence: "inferred", + rawResult: { original: true }, + }, + ); + const before = JSON.stringify(source); + const inspect = vi.fn(() => { + throw new Error("portable read must not rerun provider"); + }); + const harness = await createApplicationMcpHarness({ + serverOptions: { evmInterface: new EvmInterfaceService({ inspect }) }, + }); + onTestFinished(harness.close); + const response = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: source }, + view: { kind: "summary" }, + }, + }); + if (scenario === "valid") { + expect(response.isError).not.toBe(true); + const derived = parseEvidence(response.structuredContent); + const view = evmInterfaceSummaryViewResultSchema.parse( + derived.normalized_result, + ); + expect(view.parent_evidence_id).toBe(source.evidence_id); + expect(view.summary.functions).toEqual([]); + expect(view.summary.discovery_completeness).toBe("unknown"); + expect(view.coverage).toMatchObject({ + total: 0, + examined: 0, + exhausted: true, + }); + expect(derived.evidence_links).toEqual([source.evidence_id]); + } else + expect(parseMcpToolError(response).error.code).toBe("invalid_request"); + expect(inspect).not.toHaveBeenCalled(); + expect(JSON.stringify(source)).toBe(before); + }, +); + +it("rejects summary before provider work when retention is unavailable and preserves complete delivery", async () => { + const value = evmInterfaceFixture(); + const inspect = vi.fn(() => + Promise.resolve(ok(evmInterfaceExecution(value))), + ); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "fixed", analysis: session }, + { evmInterface: new EvmInterfaceService({ inspect }) }, + ); + const client = new Client({ name: "evm-fixed-delivery", version: "1" }); + const [a, b] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(b); + await client.connect(a); + const summary = await client.callTool({ + name: "inspect_evm_interface", + arguments: { + path: value.artifact.path, + encoding: "hex", + detail: "summary", + }, + }); + expect(parseMcpToolError(summary).error.code).toBe( + "capability_unavailable", + ); + expect(inspect).not.toHaveBeenCalled(); + const complete = await client.callTool({ + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex" }, + }); + expect(complete.isError).not.toBe(true); + expect(parseEvidence(complete.structuredContent).normalized_result).toEqual( + value, + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } +}); From d1aaeee400e0d9911d5c53339ca3ac42dccbbba3 Mon Sep 17 00:00:00 2001 From: Fernando Pereira <140405963+imissapixel@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:10:21 +0100 Subject: [PATCH 36/93] feat(ida): support optional native IDA Free MCP in attached mode (#1544) Add an explicit attached native IDA MCP compatibility profile while preserving the legacy and headless workflows. Keep recorded input identity, complete source observations and the user's open GUI database. Batch unique callee lookups, conservatively prefilter literal strings, preserve Unicode matching and unavailable caller coverage, and require distinct-function switching in native verification. Verified with Windows IDA Pro 9.3 and the native MCP plugin built from c9d15d98e8a143fc74f6e14037c48f63889b6d6c with a loopback listener. Production CLI/MCP and an unchanged Windows whoami.exe passed. Windows IDA Free was not used. No personal paths or license registration information are included. Validation: 200 focused tests, static and documentation checks, architecture guards and discovery passed. Historical same-main warm task measurements improved from 675 to 317 ms; response token estimates fell from 16618 to 11509. Fresh-start, overall CPU and memory improvement were not established. Detailed scope is in experiments/analysis-efficiency/ida-native-windows.md. Co-authored-by: imissapixel <140405963+imissapixel@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> --- README.md | 2 +- docs/ida-provider.md | 40 +- docs/testing.md | 11 +- .../analysis-efficiency/ida-native-windows.md | 101 +++ scripts/verify-real-ida.mjs | 61 +- src/ida/IdaConfiguration.ts | 64 +- src/ida/IdaMcpConnection.ts | 47 +- src/ida/IdaNativeProtocol.test.ts | 713 ++++++++++++++++++ src/ida/IdaNativeProtocol.ts | 115 +++ src/ida/IdaOperationFailures.test.ts | 4 + src/ida/IdaOperationRunner.ts | 282 +++++-- src/ida/IdaProvider.ts | 9 +- src/ida/IdaProviderCapabilities.ts | 27 +- src/ida/IdaSessionClient.ts | 33 +- tests/boundary/mcp/idaTransport.test.ts | 46 +- .../providers/ida/calleeRelationships.test.ts | 23 + .../ida/verifierRequirements.test.ts | 68 ++ tests/conformance/ida/inventory.c | 8 +- tests/fixtures/idaMcp.ts | 21 +- 19 files changed, 1525 insertions(+), 150 deletions(-) create mode 100644 experiments/analysis-efficiency/ida-native-windows.md create mode 100644 src/ida/IdaNativeProtocol.test.ts create mode 100644 src/ida/IdaNativeProtocol.ts create mode 100644 tests/boundary/providers/ida/verifierRequirements.test.ts diff --git a/README.md b/README.md index a656171284..64475070d9 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ using your user permissions; each runtime guide describes its effects. Native formats and host support vary by provider. See [Hopper and Ghidra setup](docs/installation.md#hopper), the -[IDA guide](docs/ida-provider.md), and +[IDA guide](docs/ida-provider.md) (including an optional native MCP bridge), and [experimental Windows Ghidra support](docs/windows-ghidra-p0.md). Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md). For large binaries, raise the provider's startup deadline with diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 9b15399331..ddcaaef4ba 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -1,6 +1,6 @@ -# IDA Pro provider +# IDA provider -REA adapts an existing [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp) server into its native analysis tools. Install and configure IDA, Hex-Rays, Python, and upstream MCP using that repository's instructions. REA does not install, upgrade, activate, or license them, and does not bundle another IDA server. +REA adapts an existing IDA MCP server into its native analysis tools. Existing IDA Pro profiles use [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp); an optional native bridge for IDA Pro and IDA Free is described below. Install and configure your selected engine and upstream MCP using their instructions. REA does not install, upgrade, activate, or license them, and does not bundle another IDA server. Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery. @@ -32,7 +32,33 @@ Open the **original input binary** in REA, not an `.idb` or `.i64` database. Its REA's close operation releases its MCP connection or proxy. It never saves or closes the existing GUI database. External edits remain visible because results are live and are not replayed from snapshots. These checks do not lock the GUI or prove that the database is unpatched. -The legacy producer moves the active GUI as a side effect: assembly and raw function instructions jump to the function, and successful pseudocode opens or reuses a pseudocode view. A function dossier does both. `binary_session` reports these operations with `effects.may_show_ui: true` in attached mode; headless operations and the other attached operations report `false`. +## Optional native IDA MCP bridge + +IDA Pro and IDA Free can use the separate [ida-free-mcp native plugin](https://github.com/0xshlomil/ida-free-mcp) through an explicit attached registration. Install and start that plugin using its upstream instructions, open the original binary in the IDA GUI, and wait for analysis. The native MCP 1.1.2 release binds `0.0.0.0` without a listen-address setting. Restrict inbound access with the host firewall or use an upstream build that binds only to loopback before starting it. REA's loopback URL restriction does not change the plugin's listener. REA does not install IDA, build the plugin, activate a license, or launch the GUI. + +```json +{ + "mode": "attached", + "protocol": "native", + "url": "http://127.0.0.1:13337/mcp" +} +``` + +Set `REA_IDA_MCP_CONFIG` to this file and select `ida` using the same CLI/MCP commands above. Omitting `protocol` preserves existing IDA Pro registrations. Native mode requires `resources/read` for `ida://idb/metadata` and its recorded input SHA-256; it never substitutes a digest of an unrelated local file. The native resource's `path` describes the IDB in the upstream protocol, not the original input path. REA retains it and checks database identity and image base before and after requests. + +This experimental profile provides function/string inventory and search, function lookup, assembly, GUI pseudocode, callees, address xrefs, instruction windows, and function dossiers. Direct callers remain unavailable: native code/data xref labels do not prove call edges. It requires neither IDAPython nor idalib. Native headless registrations are rejected. + +Native string observations use upstream C-string rendering; other representations remain unknown. Literal searches request conservative candidates and apply REA's original case and Unicode matching rules. Regex searches retain the full scan because native byte-oriented regex semantics cannot replace REA's Unicode regex semantics. All candidate pages are examined, and upstream search errors remain failures. + +Native callee resolution batches unique queries and checks each reply's query identity. Results remain live: a recorded input hash does not establish database revision, so cross-request analysis caches cannot prove freshness. For focused questions, use pseudocode, callees, or instruction-window tools; a function dossier requests additional facets and returns more Evidence. + +IDA Free's cloud decompiler needs network access and may request consent. Follow its licensing and supported architecture limits; see [IDA Free](https://hex-rays.com/ida-free). Decompilation moves GUI focus and the upstream plugin closes existing pseudocode tabs before requesting fresh output. REA advertises UI/network effects and closes only its MCP connection, preserving the open database. Rendered pseudocode has upstream traversal limits and does not prove complete source recovery. Cloud/widget failures remain explicit failures; retry after the GUI finishes or dismiss its prompt. + +Native MCP 1.1.2 can navigate in the previously focused pseudocode tab when switching functions. Use upstream 1.1.3 or a source build including [the navigation correction](https://github.com/0xshlomil/ida-free-mcp/commit/6adf2c615a2a03661059673e9c136ef28d0dc778), which focuses the disassembly view **before** jumping to the requested entry. This REA profile does not repair stale GUI output. Verification must include two different functions before claiming reliable function switching. The initial Linux verification used a locally corrected 1.1.2 plugin; upstream 1.1.3 has not been verified by that run. + +Native tool observations retain actual tool names and complete JSON text. Upstream `structuredContent` can be a shortened preview even while text contains the full result. REA reads the full native representation and rejects missing/malformed text instead of treating previews as complete observations. + +The legacy producer moves the active GUI as a side effect: assembly and raw function instructions jump to the function, and successful pseudocode opens or reuses a pseudocode view. A function dossier does both. Native GUI decompilation also changes the active view. `binary_session` reports these operations with `effects.may_show_ui: true`; headless operations and the other attached operations report `false`. ## Give REA a target for headless analysis @@ -98,9 +124,9 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client